ep_comments_page 11.1.16 → 11.1.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/README.md +68 -0
  2. package/commentManager.js +14 -5
  3. package/ep.json +1 -0
  4. package/exportHTML.js +36 -8
  5. package/index.js +151 -16
  6. package/locales/en.json +5 -0
  7. package/package.json +2 -3
  8. package/static/css/comment.css +188 -2
  9. package/static/js/commentBoxes.js +31 -10
  10. package/static/js/copyPasteEvents.js +12 -13
  11. package/static/js/index.js +415 -32
  12. package/static/js/timeslider.js +367 -0
  13. package/static/tests/backend/specs/editPermissions.js +118 -0
  14. package/static/tests/backend/specs/readOnlyPad.js +38 -2
  15. package/static/tests/frontend-new/helper/comments.ts +8 -1
  16. package/static/tests/frontend-new/specs/addCommentButtonState.spec.ts +74 -0
  17. package/static/tests/frontend-new/specs/authorColor.spec.ts +58 -0
  18. package/static/tests/frontend-new/specs/commentNavigation.spec.ts +136 -0
  19. package/static/tests/frontend-new/specs/commentSuggestion.spec.ts +156 -2
  20. package/static/tests/frontend-new/specs/commentsOverview.spec.ts +63 -0
  21. package/static/tests/frontend-new/specs/crossPadCommentCopy.spec.ts +62 -0
  22. package/static/tests/frontend-new/specs/exportComments.spec.ts +65 -0
  23. package/static/tests/frontend-new/specs/floatingCommentButton.spec.ts +39 -0
  24. package/static/tests/frontend-new/specs/highContrast.spec.ts +65 -0
  25. package/static/tests/frontend-new/specs/mobilePopup.spec.ts +64 -0
  26. package/static/tests/frontend-new/specs/multipleCommentsPerLine.spec.ts +62 -0
  27. package/static/tests/frontend-new/specs/readonlyButton.spec.ts +28 -0
  28. package/static/tests/frontend-new/specs/readonlyCommenting.spec.ts +40 -0
  29. package/static/tests/frontend-new/specs/readonlyHideActions.spec.ts +80 -0
  30. package/static/tests/frontend-new/specs/timeslider.spec.ts +225 -0
  31. package/templates/commentBarButtons.ejs +2 -2
  32. package/templates/comments.html +5 -0
  33. package/templates/commentsOverviewSetting.ejs +5 -0
@@ -7,6 +7,27 @@
7
7
  color: orange !important;
8
8
  }
9
9
 
10
+ /* High-contrast / forced-colors mode (e.g. Windows High Contrast Mode), #217.
11
+ In forced-colors mode the UA discards the fixed yellow/dark fill above, so
12
+ commented text becomes indistinguishable from the rest of the document, and
13
+ the orange open-state colour is unreadable against the highlight. Opt back
14
+ into author colours with the system "marked text" pair — the semantic
15
+ colours browsers reserve for highlighted text — so the highlight stays
16
+ visible and legible whatever the active high-contrast palette is, and mark
17
+ the open comment with an outline (which forced-colors honours) instead of a
18
+ colour that cannot guarantee contrast. */
19
+ @media (forced-colors: active) {
20
+ #innerdocbody .ace-line .comment {
21
+ background-color: Mark;
22
+ color: MarkText;
23
+ forced-color-adjust: none;
24
+ }
25
+ #innerdocbody .ace-line .comment[data-open="true"] {
26
+ color: MarkText !important;
27
+ outline: 2px solid CanvasText;
28
+ }
29
+ }
30
+
10
31
 
11
32
  /* Comment right side container */
12
33
  #comments {
@@ -26,6 +47,10 @@
26
47
  .sidebar-comment {
27
48
  position: absolute;
28
49
  width: 100%;
50
+ box-sizing: border-box;
51
+ /* Reserve room for the author-colour accent (#6) so commented boxes with and
52
+ without a known author colour stay aligned; the colour is set inline. */
53
+ border-left: 3px solid transparent;
29
54
  }
30
55
 
31
56
  /* WITH ICONS */
@@ -61,6 +86,50 @@ input.error, textarea.error {
61
86
  display: inline;
62
87
  }
63
88
 
89
+ /* #241: previous/next comment navigation arrows in the comment header. */
90
+ .comment-nav-wrapper {
91
+ float: right;
92
+ margin-left: 6px;
93
+ }
94
+ .comment-nav-prev,
95
+ .comment-nav-next {
96
+ /* A fixed, comfortably-clickable icon box. The localized label html10n writes
97
+ into the element is clipped/invisible (kept as the accessible name via
98
+ aria-label); the ‹ / › glyph is painted by ::before over the whole box. */
99
+ position: relative;
100
+ display: inline-block;
101
+ width: 1.5em;
102
+ height: 1.5em;
103
+ overflow: hidden;
104
+ white-space: nowrap;
105
+ color: transparent;
106
+ cursor: pointer;
107
+ vertical-align: middle;
108
+ }
109
+ .comment-nav-prev:before,
110
+ .comment-nav-next:before {
111
+ position: absolute;
112
+ top: 0;
113
+ left: 0;
114
+ width: 100%;
115
+ height: 100%;
116
+ line-height: 1.5em;
117
+ text-align: center;
118
+ color: #666;
119
+ font-size: 18px;
120
+ font-weight: bold;
121
+ }
122
+ .comment-nav-prev:hover:before,
123
+ .comment-nav-next:hover:before {
124
+ color: #000;
125
+ }
126
+ .comment-nav-prev:before {
127
+ content: "\2039"; /* ‹ */
128
+ }
129
+ .comment-nav-next:before {
130
+ content: "\203A"; /* › */
131
+ }
132
+
64
133
  /* COMMENT COMPACTED (Visible on right side) */
65
134
  .sidebar-comment:not(.full-display) .full-display-content {
66
135
  display: none;
@@ -118,8 +187,13 @@ input.error, textarea.error {
118
187
  }
119
188
  .suggestion-display .from-value,
120
189
  .suggestion-display .to-value {
121
- opacity: .8;
122
190
  font-style: italic;
191
+ /* #380: the colibris skin renders these values in its green --primary-color
192
+ (#64d29b), which is ~1.9:1 against the light comment background — well below
193
+ WCAG AA. Use the skin's regular text colour instead (readable everywhere,
194
+ still skin-overridable via the same token) with a dark fallback. !important
195
+ beats the core skin's non-important rule regardless of load order. */
196
+ color: var(--text-color, #2b2b2b) !important;
123
197
  }
124
198
  .suggestion-display .from-value:after, .suggestion-display .from-value:before,
125
199
  .suggestion-display .to-value:after, .suggestion-display .to-value:before {
@@ -133,7 +207,6 @@ input.error, textarea.error {
133
207
  }
134
208
  .suggestion-create .from-value {
135
209
  display: block;
136
- opacity: .8;
137
210
  font-style: italic;
138
211
  margin: 5px 0;
139
212
  }
@@ -199,6 +272,16 @@ input.error, textarea.error {
199
272
  .comment-modal {
200
273
  bottom: auto !important;
201
274
  right: auto !important;
275
+ /* Single source of truth for the viewport safe-margin so the CSS width cap
276
+ and the JS left/top clamp can't drift apart (#192). commentBoxes.js reads
277
+ this custom property back via getComputedStyle. */
278
+ --comment-modal-margin: 10px;
279
+ /* Never let the popup grow wider than the viewport, otherwise it spills off
280
+ the right edge on narrow / mobile screens regardless of its left position
281
+ (#192). Pairs with the left/right clamp in commentBoxes.js. */
282
+ max-width: calc(100% - (2 * var(--comment-modal-margin)));
283
+ box-sizing: border-box;
284
+ overflow-wrap: break-word;
202
285
  }
203
286
  .comment-modal-comment {
204
287
  padding: 0;
@@ -218,6 +301,109 @@ input.error, textarea.error {
218
301
  margin-top: 0 !important;
219
302
  }
220
303
 
304
+ /* READ-ONLY: hide write-only comment actions (edit / delete) on read-only
305
+ pads (#112). The comment sidebar and modal live in the ace_outer iframe,
306
+ which does not receive core's `readonly` body class, so the plugin tags the
307
+ outer body with `comments-readonly` itself (see static/js/index.js). The
308
+ server already rejects edits/deletes from read-only sessions; this removes
309
+ the now-dead controls from the UI. */
310
+ .comments-readonly .comment-actions-wrapper {
311
+ display: none !important;
312
+ }
313
+
314
+ /* #95: floating "add comment" button that follows the selection. Positioned
315
+ inline (in #editorcontainerbox space) by the plugin and clamped on-screen, so
316
+ it works the same on desktop and narrow/mobile viewports. It is shown
317
+ translucent so it doesn't obscure the selection, and becomes fully opaque on
318
+ hover/focus (#95). */
319
+ .floating-add-comment {
320
+ position: absolute;
321
+ display: none;
322
+ z-index: 100;
323
+ width: 26px;
324
+ height: 26px;
325
+ line-height: 26px;
326
+ text-align: center;
327
+ border-radius: 50%;
328
+ background: #fff;
329
+ box-shadow: 0 1px 4px rgba(0, 0, 0, .3);
330
+ cursor: pointer;
331
+ color: #444;
332
+ user-select: none;
333
+ opacity: .55;
334
+ transition: opacity .15s ease;
335
+ }
336
+ .floating-add-comment.visible {
337
+ display: block;
338
+ }
339
+ .floating-add-comment:hover,
340
+ .floating-add-comment:focus {
341
+ background: #f3f3f3;
342
+ opacity: 1;
343
+ }
344
+
345
+ /* #12/#5: all-comments overview panel (opened from the "Show all comments"
346
+ checkbox in the Settings pane). */
347
+ #comments-overview {
348
+ position: absolute;
349
+ top: 6px;
350
+ right: 6px;
351
+ width: 280px;
352
+ max-width: calc(100% - 12px);
353
+ max-height: 70%;
354
+ display: none;
355
+ flex-direction: column;
356
+ background: #fff;
357
+ border: 1px solid #ddd;
358
+ border-radius: 4px;
359
+ box-shadow: 0 2px 8px rgba(0, 0, 0, .25);
360
+ z-index: 101;
361
+ box-sizing: border-box;
362
+ overflow: hidden;
363
+ }
364
+ #comments-overview.visible {
365
+ display: flex;
366
+ }
367
+ .comments-overview-header {
368
+ font-weight: bold;
369
+ padding: 8px 10px;
370
+ border-bottom: 1px solid #eee;
371
+ flex: 0 0 auto;
372
+ }
373
+ .comments-overview-list {
374
+ overflow-y: auto;
375
+ flex: 1 1 auto;
376
+ }
377
+ .comments-overview-empty {
378
+ padding: 10px;
379
+ color: #888;
380
+ }
381
+ .comments-overview-row {
382
+ padding: 8px 10px;
383
+ border-bottom: 1px solid #f0f0f0;
384
+ cursor: pointer;
385
+ border-left: 3px solid transparent;
386
+ }
387
+ .comments-overview-row:hover {
388
+ background: #f5f5f5;
389
+ }
390
+ .comments-overview-row.resolved {
391
+ opacity: .6;
392
+ text-decoration: line-through;
393
+ }
394
+ .comments-overview-author {
395
+ font-weight: bold;
396
+ margin-right: 6px;
397
+ }
398
+
399
+ /* #96: visually mark the toolbar "Add comment" button as unavailable until
400
+ text is selected (commenting needs a selection). It is also not activatable
401
+ while disabled (see the click guard in static/js/index.js). */
402
+ .addComment.comment-btn-disabled {
403
+ opacity: .4;
404
+ cursor: not-allowed;
405
+ }
406
+
221
407
  /* OTHER */
222
408
  .hidden {
223
409
  display: none;
@@ -52,8 +52,14 @@ const highlightComment = (commentId, e, editorComment) => {
52
52
  // make a full copy of the html, including listeners
53
53
  const commentElmCloned = commentElm.clone(true, true);
54
54
 
55
- // before of appending clear the css (like top positionning)
55
+ // Clear only the sidebar positioning so the clone sits correctly in the
56
+ // modal — but preserve the author-colour accent (#6), which insertComment()
57
+ // sets as an inline border-left and would otherwise be wiped by clearing
58
+ // the whole style attribute (#436). Read the inline value off the original
59
+ // element (the clone may be detached, so computed styles aren't available).
60
+ const authorBorder = commentElm[0] && commentElm[0].style.borderLeft;
56
61
  commentElmCloned.attr('style', '');
62
+ if (authorBorder) commentElmCloned.css('border-left', authorBorder);
57
63
  // fix checkbox, because as we are duplicating the sidebar-comment, we lose unique input names
58
64
  commentElmCloned.find('.label-suggestion-checkbox').click(function () {
59
65
  $(this).siblings('input[type="checkbox"]').click();
@@ -65,26 +71,41 @@ const highlightComment = (commentId, e, editorComment) => {
65
71
  // get modal position
66
72
  const containerWidth = getPadOuter().find('#outerdocbody').outerWidth(true);
67
73
  const modalWitdh = getPadOuter().find('.comment-modal').outerWidth(true);
68
- let targetLeft = e.clientX;
69
- let targetTop = $(e.target).offset().top;
74
+ // Be tolerant of being called without a (complete) event — e.g. programmatic
75
+ // navigation (#241). Fall back to the anchor element's position so we never
76
+ // dereference a missing event (clientX/target).
77
+ const $anchor = (e && e.target) ? $(e.target) : (editorComment || commentElm);
78
+ const anchorOffset = $anchor.offset() || {left: 0, top: 0};
79
+ let targetLeft = (e && typeof e.clientX === 'number') ? e.clientX : anchorOffset.left;
80
+ let targetTop = anchorOffset.top;
70
81
  if (editorComment) {
71
82
  targetLeft += padInner.offset().left;
72
83
  targetTop += parseInt(padInner.css('padding-top').split('px')[0]);
73
84
  targetTop += parseInt(padOuter.find('#outerdocbody').css('padding-top').split('px')[0]);
74
85
  } else {
75
86
  // mean we are clicking from a comment Icon
76
- targetLeft = $(e.target).offset().left - 20;
87
+ targetLeft = anchorOffset.left - 20;
77
88
  }
78
89
 
79
- // if positioning modal on target left will make part of the modal to be
80
- // out of screen, we place it closer to the middle of the screen
81
- if (targetLeft + modalWitdh > containerWidth) {
82
- targetLeft = containerWidth - modalWitdh - 25;
83
- }
90
+ // Clamp horizontally so the modal never spills off either edge. The old
91
+ // code only guarded the right edge, which left the popup half off-screen
92
+ // (or pushed it past the left edge from the icon's `offset - 20`) on
93
+ // narrow / mobile viewports (#192). max() keeps the left margin valid even
94
+ // when the modal is wider than the container.
95
+ // Read the safe-margin from the CSS custom property so JS and CSS stay in
96
+ // sync (single source of truth on .comment-modal); fall back to 10px.
97
+ const $modal = getPadOuter().find('.comment-modal');
98
+ const marginProp = $modal.length
99
+ ? parseFloat(getComputedStyle($modal[0]).getPropertyValue('--comment-modal-margin'))
100
+ : NaN;
101
+ const margin = Number.isFinite(marginProp) ? marginProp : 10;
102
+ const maxLeft = Math.max(margin, containerWidth - modalWitdh - margin);
103
+ targetLeft = Math.min(Math.max(targetLeft, margin), maxLeft);
84
104
  const editorCommentHeight = editorComment ? editorComment.outerHeight(true) : 30;
105
+ targetTop = Math.max(margin, targetTop + editorCommentHeight);
85
106
  getPadOuter().find('.comment-modal').addClass('popup-show').css({
86
107
  left: `${targetLeft}px`,
87
- top: `${targetTop + editorCommentHeight}px`,
108
+ top: `${targetTop}px`,
88
109
  });
89
110
  }
90
111
  };
@@ -1,6 +1,5 @@
1
1
  'use strict';
2
2
 
3
- const _ = require('underscore');
4
3
  const randomString = require('ep_etherpad-lite/static/js/pad_utils').randomString;
5
4
  const shared = require('./shared');
6
5
 
@@ -49,15 +48,15 @@ exports.addTextOnClipboard = (e, ace, padInner, removeSelection, comments, repli
49
48
 
50
49
  const getReplyData = (replies, commentIds) => {
51
50
  let replyData = {};
52
- _.each(commentIds, (commentId) => {
53
- replyData = _.extend(getRepliesFromCommentId(replies, commentId), replyData);
51
+ commentIds.forEach((commentId) => {
52
+ replyData = Object.assign(getRepliesFromCommentId(replies, commentId), replyData);
54
53
  });
55
54
  return replyData;
56
55
  };
57
56
 
58
57
  const getRepliesFromCommentId = (replies, commentId) => {
59
58
  const repliesFromCommentID = {};
60
- _.each(replies, (reply, replyId) => {
59
+ Object.entries(replies || {}).forEach(([replyId, reply]) => {
61
60
  if (reply.commentId === commentId) {
62
61
  repliesFromCommentID[replyId] = reply;
63
62
  }
@@ -67,7 +66,7 @@ const getRepliesFromCommentId = (replies, commentId) => {
67
66
 
68
67
  const buildCommentIdToFakeIdMap = (commentsData) => {
69
68
  const commentIdToFakeId = {};
70
- _.each(commentsData, (comment, fakeCommentId) => {
69
+ Object.entries(commentsData).forEach(([fakeCommentId, comment]) => {
71
70
  const commentId = comment.data.originalCommentId;
72
71
  commentIdToFakeId[commentId] = fakeCommentId;
73
72
  });
@@ -76,7 +75,7 @@ const buildCommentIdToFakeIdMap = (commentsData) => {
76
75
 
77
76
  const replaceCommentIdsWithFakeIds = (commentsData, html) => {
78
77
  const commentIdToFakeId = buildCommentIdToFakeIdMap(commentsData);
79
- _.each(commentIdToFakeId, (fakeCommentId, commentId) => {
78
+ Object.entries(commentIdToFakeId).forEach(([commentId, fakeCommentId]) => {
80
79
  $(html).find(`.${commentId}`).removeClass(commentId).addClass(fakeCommentId);
81
80
  });
82
81
  const htmlWithFakeCommentIds = getHtml(html);
@@ -86,7 +85,7 @@ const replaceCommentIdsWithFakeIds = (commentsData, html) => {
86
85
  const buildCommentsData = (html, comments) => {
87
86
  const commentsData = {};
88
87
  const originalCommentIds = getCommentIds(html);
89
- _.each(originalCommentIds, (originalCommentId) => {
88
+ originalCommentIds.forEach((originalCommentId) => {
90
89
  const fakeCommentId = generateFakeCommentId();
91
90
  const comment = comments[originalCommentId];
92
91
  comment.data.originalCommentId = originalCommentId;
@@ -101,9 +100,9 @@ const generateFakeCommentId = () => {
101
100
  };
102
101
 
103
102
  const getCommentIds = (html) => {
104
- const allSpans = $(html).find('span');
103
+ const allSpans = $(html).find('span').toArray();
105
104
  const commentIds = [];
106
- _.each(allSpans, (span) => {
105
+ allSpans.forEach((span) => {
107
106
  const cls = $(span).attr('class');
108
107
  const classCommentId = /(?:^| )(c-[A-Za-z0-9]*)/.exec(cls);
109
108
  const commentId = (classCommentId) ? classCommentId[1] : false;
@@ -217,7 +216,7 @@ const saveComments = (comments) => {
217
216
  const mapOriginalCommentsId = pad.plugins.ep_comments_page.mapOriginalCommentsId;
218
217
  const mapFakeComments = pad.plugins.ep_comments_page.mapFakeComments;
219
218
 
220
- _.each(comments, (comment, fakeCommentId) => {
219
+ Object.entries(comments).forEach(([fakeCommentId, comment]) => {
221
220
  const newCommentId = shared.generateCommentId();
222
221
  mapFakeComments[fakeCommentId] = newCommentId;
223
222
  const originalCommentId = comment.data.originalCommentId;
@@ -231,7 +230,7 @@ const saveReplies = (replies) => {
231
230
  const repliesToSave = {};
232
231
  const padId = clientVars.padId;
233
232
  const mapOriginalCommentsId = pad.plugins.ep_comments_page.mapOriginalCommentsId;
234
- _.each(replies, (reply, replyId) => {
233
+ Object.entries(replies).forEach(([replyId, reply]) => {
235
234
  const originalCommentId = reply.commentId;
236
235
  // as the comment copied has got a new commentId, we set this id in the reply as well
237
236
  reply.commentId = mapOriginalCommentsId[originalCommentId];
@@ -258,7 +257,7 @@ const htmlDecode = (input) => {
258
257
  exports.getCommentIdOnFirstPositionSelected = function () {
259
258
  const attributeManager = this.documentAttributeManager;
260
259
  const rep = this.rep;
261
- const commentId = _.object(
260
+ const commentId = Object.fromEntries(
262
261
  attributeManager.getAttributesOnPosition(rep.selStart[0], rep.selStart[1])).comment;
263
262
  return commentId;
264
263
  };
@@ -311,7 +310,7 @@ const hasCommentOnLine = (lineNumber, firstColumn, lastColumn, attributeManager)
311
310
  let foundCommentOnLine = false;
312
311
  for (let column = firstColumn; column <= lastColumn && !foundCommentOnLine; column++) {
313
312
  const commentId =
314
- _.object(attributeManager.getAttributesOnPosition(lineNumber, column)).comment;
313
+ Object.fromEntries(attributeManager.getAttributesOnPosition(lineNumber, column)).comment;
315
314
  if (commentId !== undefined) {
316
315
  foundCommentOnLine = true;
317
316
  }