Skip to content

Commit 7623e05

Browse files
committed
feat(events): quote comments in replies
* Added a button in the comment action dropdown to qute that comment's contents in the new comment. When clicked, it appends the comment's contents to the RichEditor (inside a blockquote). * Added a tooltip that renders above any text highlighted within comments and is dynamically positioned to be in the midpoint of the selection. The tooltip contains a "Quote" button and a "Copy" button. When "Quote" is clicked, it performs the same action as above, but just for the selected portion of text. * Formatting is only kept in the quote for the first method, due to the complexities of propagating HTML formatting for partial selections. * The blockquote currently has no real pointer to the quoted comment, and is freely editable by the user.
1 parent eee5a89 commit 7623e05

File tree

8 files changed

+174
-8
lines changed

8 files changed

+174
-8
lines changed

invenio_requests/assets/semantic-ui/js/invenio_requests/components/TimelineEventBody.js

Lines changed: 82 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,96 @@
44
// Invenio RDM Records is free software; you can redistribute it and/or modify it
55
// under the terms of the MIT License; see LICENSE file for more details.
66

7-
import React from "react";
7+
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
88
import PropTypes from "prop-types";
9+
import { Button, Popup } from "semantic-ui-react";
10+
import { i18next } from "@translations/invenio_requests/i18next";
911

10-
export const TimelineEventBody = ({ content, format }) => {
11-
return format === "html" ? (
12-
<span dangerouslySetInnerHTML={{ __html: content }} />
13-
) : (
14-
content
12+
export const TimelineEventBody = ({ content, format, quote }) => {
13+
const ref = useRef(null);
14+
const [selectionRange, setSelectionRange] = useState(null);
15+
16+
useEffect(() => {
17+
if (ref.current === null) return;
18+
19+
const onSelectionChange = () => {
20+
const selection = window.getSelection();
21+
22+
// anchorNode is where the user started dragging the mouse,
23+
// focusNode is where they finished. We make sure both nodes
24+
// are contained by the ref so we are sure that 100% of the selection
25+
// is within this comment event.
26+
const selectionIsContainedByRef =
27+
ref.current.contains(selection.anchorNode) &&
28+
ref.current.contains(selection.focusNode);
29+
30+
if (
31+
!selectionIsContainedByRef ||
32+
selection.rangeCount === 0 ||
33+
// A "Caret" type e.g. should not trigger a tooltip
34+
selection.type !== "Range"
35+
) {
36+
setSelectionRange(null);
37+
return;
38+
}
39+
40+
setSelectionRange(selection.getRangeAt(0));
41+
};
42+
43+
document.addEventListener("selectionchange", onSelectionChange);
44+
return () => document.removeEventListener("selectionchange", onSelectionChange);
45+
}, [ref]);
46+
47+
const tooltipOffset = useMemo(() => {
48+
if (!selectionRange) return null;
49+
50+
const selectionRect = selectionRange.getBoundingClientRect();
51+
const refRect = ref.current.getBoundingClientRect();
52+
53+
// Offset set as [x, y] from the reference position.
54+
// E.g. `top left` is relative to [0,0] but `top center` is relative to [{center}, 0]
55+
return [selectionRect.x - refRect.x, -(selectionRect.y - refRect.y)];
56+
}, [selectionRange]);
57+
58+
const onQuoteClick = useCallback(() => {
59+
if (!selectionRange) return;
60+
quote(selectionRange.toString());
61+
window.getSelection().removeAllRanges();
62+
}, [selectionRange, quote]);
63+
64+
return (
65+
<Popup
66+
eventsEnabled={false}
67+
open={!!tooltipOffset}
68+
offset={tooltipOffset}
69+
position="top left"
70+
className="requests-event-body-popup"
71+
trigger={
72+
<span ref={ref}>
73+
{format === "html" ? (
74+
<span dangerouslySetInnerHTML={{ __html: content }} />
75+
) : (
76+
content
77+
)}
78+
</span>
79+
}
80+
basic
81+
>
82+
<Button
83+
onClick={onQuoteClick}
84+
icon="quote left"
85+
content={i18next.t("Quote")}
86+
size="small"
87+
basic
88+
/>
89+
</Popup>
1590
);
1691
};
1792

1893
TimelineEventBody.propTypes = {
1994
content: PropTypes.string,
2095
format: PropTypes.string,
96+
quote: PropTypes.func.isRequired,
2197
};
2298

2399
TimelineEventBody.defaultProps = {

invenio_requests/assets/semantic-ui/js/invenio_requests/timelineCommentEditor/TimelineCommentEditor.js

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,45 @@
55
// under the terms of the MIT License; see LICENSE file for more details.
66

77
import { RichEditor } from "react-invenio-forms";
8-
import React, { useEffect } from "react";
8+
import React, { useEffect, useRef } from "react";
99
import { SaveButton } from "../components/Buttons";
1010
import { Container, Message } from "semantic-ui-react";
1111
import PropTypes from "prop-types";
1212
import { i18next } from "@translations/invenio_requests/i18next";
1313
import { RequestEventAvatarContainer } from "../components/RequestsFeed";
1414

15+
// Make content inside the editor look identical to how we will render it in a comment.
16+
// TinyMCE runs within an iframe, so we cannot style it with page-wide CSS styles as normal.
17+
//
18+
// TinyMCE overrides blockquotes with custom styles, so we need to use !important to override
19+
// the overrides in a consistent and reliable way.
20+
// https://github.com/tinymce/tinymce-dist/blob/8d7491f2ee341c201b68cc7c3701d54703edd474/skins/content/tinymce-5/content.css#L61-L70
21+
//
22+
// The body styles are included in the `content_style` in RichEditor, so we need to include it here too.
23+
const editorContentStyle = `
24+
body {
25+
font-size: 14px;
26+
}
27+
28+
blockquote {
29+
margin-left: 0.5rem !important;
30+
padding-left: 0.75rem !important;
31+
color: #757575;
32+
border-left: 4px solid #C5C5C5 !important;
33+
}
34+
35+
blockquote > blockquote {
36+
margin-left: 0 !important;
37+
}
38+
`;
39+
1540
const TimelineCommentEditor = ({
1641
isLoading,
1742
commentContent,
1843
storedCommentContent,
1944
restoreCommentContent,
2045
setCommentContent,
46+
appendedCommentContent,
2147
error,
2248
submitComment,
2349
userAvatar,
@@ -26,6 +52,16 @@ const TimelineCommentEditor = ({
2652
restoreCommentContent();
2753
}, [restoreCommentContent]);
2854

55+
const editorRef = useRef(null);
56+
useEffect(() => {
57+
if (!appendedCommentContent) return;
58+
// Move the caret to the end of the body and focus the editor.
59+
// See https://www.tiny.cloud/blog/set-and-get-cursor-position/#h_48266906174501699933284256
60+
editorRef.current.selection.select(editorRef.current.getBody(), true);
61+
editorRef.current.selection.collapse(false);
62+
editorRef.current.focus();
63+
}, [appendedCommentContent]);
64+
2965
return (
3066
<div className="timeline-comment-editor-container">
3167
{error && <Message negative>{error}</Message>}
@@ -39,10 +75,14 @@ const TimelineCommentEditor = ({
3975
inputValue={commentContent}
4076
// initialValue is not allowed to change, so we use `storedCommentContent` which is set at most once
4177
initialValue={storedCommentContent}
42-
onEditorChange={(event, editor) => {
78+
onEditorChange={(_, editor) => {
4379
setCommentContent(editor.getContent());
4480
}}
4581
minHeight={150}
82+
onInit={(_, editor) => (editorRef.current = editor)}
83+
editorConfig={{
84+
content_style: editorContentStyle,
85+
}}
4686
/>
4787
</Container>
4888
</div>
@@ -62,6 +102,7 @@ const TimelineCommentEditor = ({
62102
TimelineCommentEditor.propTypes = {
63103
commentContent: PropTypes.string,
64104
storedCommentContent: PropTypes.string,
105+
appendedCommentContent: PropTypes.string,
65106
isLoading: PropTypes.bool,
66107
setCommentContent: PropTypes.func.isRequired,
67108
error: PropTypes.string,
@@ -73,6 +114,7 @@ TimelineCommentEditor.propTypes = {
73114
TimelineCommentEditor.defaultProps = {
74115
commentContent: "",
75116
storedCommentContent: null,
117+
appendedCommentContent: "",
76118
isLoading: false,
77119
error: "",
78120
userAvatar: "",

invenio_requests/assets/semantic-ui/js/invenio_requests/timelineCommentEditor/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const mapStateToProps = (state) => ({
1919
error: state.timelineCommentEditor.error,
2020
commentContent: state.timelineCommentEditor.commentContent,
2121
storedCommentContent: state.timelineCommentEditor.storedCommentContent,
22+
appendedCommentContent: state.timelineCommentEditor.appendedCommentContent,
2223
});
2324

2425
export const TimelineCommentEditor = connect(

invenio_requests/assets/semantic-ui/js/invenio_requests/timelineCommentEditor/state/actions.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export const HAS_ERROR = "eventEditor/HAS_ERROR";
1818
export const SUCCESS = "eventEditor/SUCCESS";
1919
export const SETTING_CONTENT = "eventEditor/SETTING_CONTENT";
2020
export const RESTORE_CONTENT = "eventEditor/RESTORE_CONTENT";
21+
export const APPEND_CONTENT = "eventEditor/APPENDING_CONTENT";
2122

2223
const draftCommentKey = (requestId) => `draft-comment-${requestId}`;
2324
const setDraftComment = (requestId, content) => {
@@ -68,6 +69,15 @@ export const restoreEventContent = () => {
6869
};
6970
};
7071

72+
export const appendEventContent = (content, focus) => {
73+
return async (dispatch, getState, config) => {
74+
dispatch({
75+
type: APPEND_CONTENT,
76+
payload: content,
77+
});
78+
};
79+
};
80+
7181
export const submitComment = (content, format) => {
7282
return async (dispatch, getState, config) => {
7383
const { timeline: timelineState, request } = getState();

invenio_requests/assets/semantic-ui/js/invenio_requests/timelineCommentEditor/state/reducer.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,19 +11,29 @@ import {
1111
SUCCESS,
1212
SETTING_CONTENT,
1313
RESTORE_CONTENT,
14+
APPEND_CONTENT,
1415
} from "./actions";
1516

1617
const initialState = {
1718
error: null,
1819
isLoading: false,
1920
commentContent: "",
2021
storedCommentContent: null,
22+
appendedCommentContent: "",
2123
};
2224

2325
export const commentEditorReducer = (state = initialState, action) => {
2426
switch (action.type) {
2527
case SETTING_CONTENT:
2628
return { ...state, commentContent: action.payload };
29+
case APPEND_CONTENT:
30+
return {
31+
...state,
32+
commentContent: state.commentContent + action.payload,
33+
// We keep track of appended content separately to trigger the focus event only when
34+
// text is appended (not when the user is typing).
35+
appendedCommentContent: state.appendedCommentContent + action.payload,
36+
};
2737
case IS_LOADING:
2838
return { ...state, isLoading: true };
2939
case HAS_ERROR:

invenio_requests/assets/semantic-ui/js/invenio_requests/timelineCommentEventControlled/TimelineCommentEventControlled.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,23 @@ class TimelineCommentEventControlled extends Component {
5959
openConfirmModal(() => deleteComment({ event }));
6060
};
6161

62+
/**
63+
* Append a quote of the comment's content to the new comment in the editor.
64+
*
65+
* @param {string} [text] - A subset of the text to quote. If null, the entire comment is quoted.
66+
*/
67+
quote = (text) => {
68+
const {
69+
appendCommentContent,
70+
event: {
71+
payload: { content },
72+
},
73+
} = this.props;
74+
75+
const quotedText = text ?? content;
76+
appendCommentContent(`<blockquote>${quotedText}</blockquote><br />`);
77+
};
78+
6279
render() {
6380
const { event } = this.props;
6481
const { isLoading, isEditing, error } = this.state;
@@ -69,6 +86,7 @@ class TimelineCommentEventControlled extends Component {
6986
updateComment={this.updateComment}
7087
deleteComment={this.deleteComment}
7188
toggleEditMode={this.toggleEditMode}
89+
quote={this.quote}
7290
isLoading={isLoading}
7391
isEditing={isEditing}
7492
error={error}
@@ -83,6 +101,7 @@ TimelineCommentEventControlled.propTypes = {
83101
event: PropTypes.object.isRequired,
84102
updateComment: PropTypes.func.isRequired,
85103
deleteComment: PropTypes.func.isRequired,
104+
appendCommentContent: PropTypes.func.isRequired,
86105
openConfirmModal: PropTypes.func.isRequired,
87106
};
88107

invenio_requests/assets/semantic-ui/js/invenio_requests/timelineCommentEventControlled/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@
77
import EventWithStateComponent from "./TimelineCommentEventControlled";
88
import { connect } from "react-redux";
99
import { updateComment, deleteComment } from "./state/actions";
10+
import { appendEventContent } from "../timelineCommentEditor/state/actions";
1011

1112
const mapDispatchToProps = (dispatch) => ({
1213
updateComment: async (payload) => dispatch(updateComment(payload)),
1314
deleteComment: async (payload) => dispatch(deleteComment(payload)),
15+
appendCommentContent: (content) => dispatch(appendEventContent(content)),
1416
});
1517

1618
export const TimelineCommentEventControlled = connect(

invenio_requests/assets/semantic-ui/js/invenio_requests/timelineEvents/TimelineCommentEvent.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ class TimelineCommentEvent extends Component {
5050
updateComment,
5151
deleteComment,
5252
toggleEditMode,
53+
quote,
5354
} = this.props;
5455
const { commentContent } = this.state;
5556

@@ -88,6 +89,9 @@ class TimelineCommentEvent extends Component {
8889
aria-label={i18next.t("Actions")}
8990
>
9091
<Dropdown.Menu>
92+
<Dropdown.Item onClick={() => quote()}>
93+
{i18next.t("Quote")}
94+
</Dropdown.Item>
9195
{canUpdate && (
9296
<Dropdown.Item onClick={() => toggleEditMode()}>
9397
{i18next.t("Edit")}
@@ -126,6 +130,7 @@ class TimelineCommentEvent extends Component {
126130
<TimelineEventBody
127131
content={event?.payload?.content}
128132
format={event?.payload?.format}
133+
quote={quote}
129134
/>
130135
)}
131136

@@ -154,6 +159,7 @@ TimelineCommentEvent.propTypes = {
154159
deleteComment: PropTypes.func.isRequired,
155160
updateComment: PropTypes.func.isRequired,
156161
toggleEditMode: PropTypes.func.isRequired,
162+
quote: PropTypes.func.isRequired,
157163
isLoading: PropTypes.bool,
158164
isEditing: PropTypes.bool,
159165
error: PropTypes.string,

0 commit comments

Comments
 (0)