@raketa-cloud/admin 1.10.11 → 1.10.13

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 (42) hide show
  1. package/dist/application.css +699 -244
  2. package/package.json +4 -3
  3. package/src/cloud/Comments.js +88 -0
  4. package/src/cloud/Editors.js +20 -0
  5. package/src/cloud/ResourceChanges.js +1 -1
  6. package/src/cloud/documents/commentsDocuments.js +103 -0
  7. package/src/cloud/documents/editorsDocuments.js +27 -0
  8. package/src/handlers/resources/DiscussionsHandler.js +203 -0
  9. package/src/handlers/resources/HistoryHandler.js +7 -5
  10. package/src/handlers/resources/ResourcesHandler.js +6 -5
  11. package/src/helpers/commentMarkdown.js +94 -0
  12. package/src/helpers/fetchCommentCounts.js +14 -0
  13. package/src/helpers/historyUtils.js +11 -0
  14. package/src/helpers/utils.js +24 -0
  15. package/src/index.js +1 -0
  16. package/src/routeResourceModule.js +42 -0
  17. package/src/setupCloud.js +4 -0
  18. package/src/styles/application.css +56 -0
  19. package/src/tests/cloud/Comments.test.js +141 -0
  20. package/src/tests/cloud/Editors.test.js +47 -0
  21. package/src/tests/cloud/ResourceChanges.test.js +25 -0
  22. package/src/tests/handlers/resources/DiscussionsHandler.test/create.test.js +108 -0
  23. package/src/tests/handlers/resources/DiscussionsHandler.test/destroy.test.js +55 -0
  24. package/src/tests/handlers/resources/DiscussionsHandler.test/index.test.js +195 -0
  25. package/src/tests/handlers/resources/DiscussionsHandler.test/update.test.js +87 -0
  26. package/src/tests/handlers/resources/HistoryHandler.test/index.test.js +9 -0
  27. package/src/tests/handlers/resources/ResourcesHandler.test/show.test.js +33 -0
  28. package/src/tests/handlers/resources/ResourcesHandler.test/show_reports.test.js +3 -0
  29. package/src/tests/helpers/commentMarkdown.test.js +166 -0
  30. package/src/tests/helpers/fetchCommentCounts.test.js +42 -0
  31. package/src/tests/helpers/historyUtils.test.js +26 -0
  32. package/src/tests/helpers/utils.test.js +21 -0
  33. package/src/tests/routeResourceModule.test.js +82 -2
  34. package/src/tests/setupCloud.test.js +4 -0
  35. package/src/tests/views/discussions.test.js +519 -0
  36. package/src/views/resources/_shared/_history_toolbar.ejs +10 -0
  37. package/src/views/resources/_shared/_tabs.ejs +21 -0
  38. package/src/views/resources/_shared/_toolbar.ejs +1 -1
  39. package/src/views/resources/discussions/_avatar.ejs +13 -0
  40. package/src/views/resources/discussions/_discussion_tabs.ejs +22 -0
  41. package/src/views/resources/discussions/index.ejs +729 -0
  42. package/src/views/resources/show.ejs +17 -0
@@ -0,0 +1,729 @@
1
+ <%
2
+ const list = (comments && comments.list) || [];
3
+ const formPath = discussion.path;
4
+ // The discussions of published revisions are kept as they were.
5
+ const readOnly = discussion.archived;
6
+ const currentEmail = (currentEditor.email || "").toLowerCase();
7
+ const commentsPath = `/${_context.locale}/${_context.resourceConfig.resourceTypePlural}/${resource.id}/comments`;
8
+
9
+ const textareaClass = "w-full px-3 py-2 text-sm border rounded shadow-inner resize-y border-neutral-300 bg-raketa-background dark:bg-neutral-800 dark:border-neutral-700 dark:focus:border-raketa-primary dark:focus:ring-raketa-primary";
10
+
11
+ // Editors are picked by the name, or the email of editors without one. The
12
+ // comments are stored with "@editor-<id>" in place of the names.
13
+ const mentionLabel = (editor) => (editor.name || "").trim() || editor.email || "";
14
+ const mentionable = (locals.editors || []).filter((editor) => mentionLabel(editor));
15
+
16
+ const deletedMessage = "This comment has been deleted.";
17
+
18
+ const snippet = (text, mentions) => {
19
+ const plain = utils.commentMentionsText(text, mentions);
20
+
21
+ return plain.length > 90 ? `${plain.substring(0, 90)}…` : plain;
22
+ };
23
+ %>
24
+
25
+ <div class="page-wrapper">
26
+ <div class="page-title">
27
+ <%- render_partial('../_shared/_title.ejs') %>
28
+ <%- render_partial('../_shared/_toolbar.ejs', { render_partial, base: '..' }) %>
29
+ </div>
30
+
31
+ <%# Archived discussions are reached from the history, not the Discussions tab. %>
32
+ <%- render_partial('../_shared/_tabs.ejs', { active: readOnly ? null : 'discussions', spacing: 'mb-4' }) %>
33
+
34
+ <% if (readOnly) { %>
35
+ <div class="flex items-center justify-between max-w-3xl gap-4 px-4 py-3 mb-8 text-sm border rounded border-mono-purple-100 bg-raketa-primary/5">
36
+ <div>
37
+ <span class="font-semibold">Revision #<%= discussion.revisionId %></span>
38
+ <span class="text-raketa-foreground/50">· This discussion is archived</span>
39
+ </div>
40
+
41
+ <a href="<%= `/${_context.locale}/${_context.resourceConfig.resourceTypePlural}/${resource.id}/history` %>" class="text-xs btn-text whitespace-nowrap">Back to history</a>
42
+ </div>
43
+ <% } else { %>
44
+ <%- render_partial('./_discussion_tabs.ejs', { revisions, discussion }) %>
45
+ <% } %>
46
+
47
+ <div class="max-w-3xl pb-8">
48
+ <% if (!readOnly) { %>
49
+ <form method="POST" action="<%= formPath %>" id="comment-form" class="flex gap-4 pb-8 mb-8 border-b border-neutral-200 dark:border-neutral-700">
50
+ <%- render_partial('./_avatar.ejs', { editor: currentEditor }) %>
51
+
52
+ <div class="flex-1 min-w-0">
53
+ <% if (_context.query?.comment_error) { %>
54
+ <div class="mb-2 text-sm text-red-600"><%= _context.query.comment_error %></div>
55
+ <% } %>
56
+
57
+ <div id="reply-banner" class="items-center justify-between hidden gap-2 px-3 py-1.5 mb-2 text-xs border-l-4 rounded-r border-raketa-primary bg-raketa-primary/5">
58
+ <div class="flex items-center min-w-0 gap-2">
59
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-3.5 shrink-0 text-raketa-primary">
60
+ <path stroke-linecap="round" stroke-linejoin="round" d="M9 15 3 9m0 0 6-6M3 9h12a6 6 0 0 1 0 12h-3" />
61
+ </svg>
62
+ <span class="shrink-0 text-raketa-foreground/50">Replying to</span>
63
+ <span id="reply-to-name" class="font-semibold shrink-0"></span>
64
+ <span id="reply-to-preview" class="italic truncate text-raketa-foreground/50"></span>
65
+ </div>
66
+
67
+ <button type="button" data-cancel-reply class="p-1 rounded text-raketa-foreground/50 hover:text-raketa-foreground" title="Cancel reply" aria-label="Cancel reply">
68
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-3.5">
69
+ <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
70
+ </svg>
71
+ </button>
72
+ </div>
73
+
74
+ <input type="hidden" name="comment[reply_id]" id="comment-reply-id" value="" />
75
+
76
+ <div class="relative" data-mention-field>
77
+ <textarea name="comment[body]" id="comment-body" rows="3" required placeholder="Leave a comment…" class="<%= textareaClass %>"></textarea>
78
+ </div>
79
+
80
+ <div class="flex justify-end mt-2">
81
+ <button type="submit" class="btn-primary">Post comment</button>
82
+ </div>
83
+ </div>
84
+ </form>
85
+ <% } %>
86
+
87
+ <% if (list.length) { %>
88
+ <div class="flex flex-col gap-7">
89
+ <% list.forEach((comment) => { %>
90
+ <%# Only authors change their comments, never in archived discussions. %>
91
+ <% const isDeleted = Boolean(comment.deletedAt); %>
92
+ <% const isMine = !readOnly && !isDeleted && currentEmail && (comment.editor?.email || "").toLowerCase() === currentEmail; %>
93
+ <div id="comment-<%= comment.id %>" class="flex gap-4 p-1 -m-1 transition-colors duration-300 rounded group/comment">
94
+ <%- render_partial('./_avatar.ejs', { editor: comment.editor }) %>
95
+
96
+ <div class="flex-1 min-w-0">
97
+ <div class="flex items-center justify-between mb-1">
98
+ <div class="flex items-center gap-2">
99
+ <span class="text-sm font-semibold"><%= comment.editor?.name || comment.editor?.email || "Unknown" %></span>
100
+ <span class="flex items-center gap-1.5 body-3 text-raketa-foreground/50">
101
+ · <time data-comment-datetime datetime="<%= comment.insertedAt %>"><%= utils.formattedDateTime(comment.insertedAt) %></time>
102
+
103
+ <% if (!readOnly) { %>
104
+ <button type="button" data-copy-link="<%= comment.id %>" title="Copy link to comment" aria-label="Copy link to comment" class="flex items-center gap-1 transition-opacity opacity-0 cursor-pointer text-raketa-foreground/50 hover:text-raketa-primary group-hover/comment:opacity-100 focus-visible:opacity-100">
105
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="-rotate-45 size-3.5">
106
+ <path stroke-linecap="round" stroke-linejoin="round" d="M9 17H7A5 5 0 0 1 7 7h2M15 7h2a5 5 0 1 1 0 10h-2M8 12h8" />
107
+ </svg>
108
+ </button>
109
+ <span data-copied aria-live="polite" class="hidden pointer-events-none select-none">Link copied</span>
110
+ <% } %>
111
+
112
+ <% if (!isDeleted && comment.updatedAt && comment.updatedAt !== comment.insertedAt) { %>
113
+ · <span title="Edited <%= utils.formattedDateTime(comment.updatedAt) %>">edited</span>
114
+ <% } %>
115
+ </span>
116
+ </div>
117
+
118
+ <% if (!readOnly && !isDeleted) { %>
119
+ <div class="flex items-center gap-2 transition-opacity opacity-0 group-hover/comment:opacity-100 focus-within:opacity-100 has-[[open]]:opacity-100">
120
+ <button type="button" class="text-xs cursor-pointer btn-text has-icon" data-reply-to="<%= comment.id %>" data-reply-author="<%= comment.editor?.name || comment.editor?.email || 'Unknown' %>" data-reply-snippet="<%= snippet(comment.body, comment.mentions) %>">
121
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-3">
122
+ <path stroke-linecap="round" stroke-linejoin="round" d="M9 15 3 9m0 0 6-6M3 9h12a6 6 0 0 1 0 12h-3" />
123
+ </svg>
124
+ Reply
125
+ </button>
126
+
127
+ <% if (isMine) { %>
128
+ <details class="relative unstyled-details" data-comment-menu>
129
+ <summary class="flex items-center justify-center rounded cursor-pointer size-6 text-raketa-foreground/50 hover:text-raketa-foreground hover:bg-raketa-primary/10" title="More actions" aria-label="More actions">
130
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4">
131
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 6.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5ZM12 12.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5ZM12 18.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5Z" />
132
+ </svg>
133
+ </summary>
134
+
135
+ <div class="absolute right-0 z-20 flex flex-col gap-4 p-4 mt-2 border rounded shadow-md border-mono-purple-100 bg-raketa-background dark:border-neutral-500">
136
+ <button type="button" data-edit-comment class="text-xs cursor-pointer btn-text has-icon whitespace-nowrap">
137
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-3">
138
+ <path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125" />
139
+ </svg>
140
+ Edit
141
+ </button>
142
+
143
+ <button type="button" hx-delete="<%= `${commentsPath}/${comment.id}` %>" hx-confirm="Delete this comment?" class="text-xs cursor-pointer btn-danger has-icon whitespace-nowrap">
144
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-3">
145
+ <path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
146
+ </svg>
147
+ Delete
148
+ </button>
149
+ </div>
150
+ </details>
151
+ <% } %>
152
+ </div>
153
+ <% } %>
154
+ </div>
155
+
156
+ <% if (comment.reply) { %>
157
+ <button type="button" data-scroll-to="<%= comment.reply.id %>" title="Jump to the quoted comment" class="flex items-start w-full max-w-full gap-1.5 px-2.5 py-1 mb-2 text-xs text-left border-l-4 rounded-r cursor-pointer bg-raketa-primary/5 border-raketa-primary/40 hover:border-raketa-primary hover:bg-raketa-primary/10">
158
+ <span class="font-semibold shrink-0"><%= comment.reply.editor?.name || comment.reply.editor?.email || "Unknown" %>:</span>
159
+ <% if (comment.reply.deletedAt) { %>
160
+ <span class="italic truncate text-raketa-foreground/50"><%= deletedMessage %></span>
161
+ <% } else { %>
162
+ <span class="truncate text-raketa-foreground/50"><%= snippet(comment.reply.body, comment.reply.mentions) %></span>
163
+ <% } %>
164
+ </button>
165
+ <% } %>
166
+
167
+ <%# The markdown is rendered with HTML disabled, so it is safe unescaped. %>
168
+ <% if (isDeleted) { %>
169
+ <p data-comment-deleted class="text-sm italic text-raketa-foreground/50"><%= deletedMessage %></p>
170
+ <% } else { %>
171
+ <div data-comment-body class="comment-body"><%- utils.commentMarkdown(comment.body, { mentions: comment.mentions }) %></div>
172
+ <% } %>
173
+
174
+ <% if (isMine) { %>
175
+ <form data-edit-form hx-put="<%= `${commentsPath}/${comment.id}` %>" class="hidden">
176
+ <input type="hidden" name="revision_id" value="<%= discussion.revisionId || '' %>" />
177
+ <div class="relative" data-mention-field>
178
+ <textarea name="comment[body]" rows="3" required aria-label="Edit comment" data-mentions="<%= JSON.stringify(utils.commentMentionIds(comment.mentions)) %>" class="<%= textareaClass %>"><%= utils.commentMentionsText(comment.body, comment.mentions) %></textarea>
179
+ </div>
180
+
181
+ <div class="flex items-center justify-end gap-4 mt-2">
182
+ <button type="button" data-cancel-edit class="btn-text">Cancel</button>
183
+ <button type="submit" class="btn-primary">Save</button>
184
+ </div>
185
+ </form>
186
+ <% } %>
187
+ </div>
188
+ </div>
189
+ <% }) %>
190
+ </div>
191
+
192
+ <% if (comments.pagination && comments.pagination.pagesCount > 1) { %>
193
+ <div class="mt-8">
194
+ <%- pagination(comments.pagination, _context) %>
195
+ </div>
196
+ <% } %>
197
+ <% } else { %>
198
+ <div class="py-12 text-sm text-center text-raketa-foreground/50"><%= readOnly ? "There are no comments in this discussion." : "No comments yet. Start the conversation!" %></div>
199
+ <% } %>
200
+ </div>
201
+
202
+ <% if (!readOnly && mentionable.length) { %>
203
+ <%# Moved next to the "@" typed in a textarea, the focus stays in it. %>
204
+ <div id="mention-picker" class="absolute z-30 hidden border rounded shadow-md w-72 border-mono-purple-100 bg-raketa-background dark:border-neutral-500">
205
+ <ul id="mention-options" role="listbox" aria-label="Editors" class="relative py-1 overflow-y-auto max-h-56">
206
+ <% mentionable.forEach((editor) => { %>
207
+ <li role="option" id="mention-option-<%= editor.id %>" aria-selected="false" data-mention-option data-editor-id="<%= editor.id %>" data-mention="<%= mentionLabel(editor) %>" data-search="<%= `${mentionLabel(editor)} ${editor.email || ''}`.toLowerCase() %>" class="flex items-center gap-2 px-3 py-1.5 text-sm cursor-pointer hover:bg-raketa-primary/5 aria-selected:bg-raketa-primary/10">
208
+ <%- render_partial('./_avatar.ejs', { editor, small: true }) %>
209
+ <span class="min-w-0">
210
+ <span class="block font-semibold truncate"><%= mentionLabel(editor) %></span>
211
+ <% if (editor.email && editor.email !== mentionLabel(editor)) { %>
212
+ <span class="block text-xs truncate text-raketa-foreground/50"><%= editor.email %></span>
213
+ <% } %>
214
+ </span>
215
+ </li>
216
+ <% }) %>
217
+ </ul>
218
+
219
+ <div data-mention-empty class="hidden px-3 py-2 text-sm text-raketa-foreground/50">No editors found</div>
220
+ </div>
221
+ <% } %>
222
+ </div>
223
+
224
+ <script>
225
+ (() => {
226
+ const form = document.getElementById("comment-form");
227
+ const body = document.getElementById("comment-body");
228
+ const replyId = document.getElementById("comment-reply-id");
229
+ const banner = document.getElementById("reply-banner");
230
+
231
+ const cancelReply = () => {
232
+ replyId.value = "";
233
+ banner.classList.add("hidden");
234
+ banner.classList.remove("flex");
235
+ };
236
+
237
+ document.querySelectorAll("[data-reply-to]").forEach((button) => {
238
+ button.addEventListener("click", () => {
239
+ replyId.value = button.dataset.replyTo;
240
+ document.getElementById("reply-to-name").textContent = button.dataset.replyAuthor;
241
+ document.getElementById("reply-to-preview").textContent = `"${button.dataset.replySnippet}"`;
242
+ banner.classList.remove("hidden");
243
+ banner.classList.add("flex");
244
+ form.scrollIntoView({
245
+ behavior: "smooth",
246
+ block: "start"
247
+ });
248
+ body.focus({
249
+ preventScroll: true
250
+ });
251
+ });
252
+ });
253
+
254
+ document.querySelector("[data-cancel-reply]")?.addEventListener("click", cancelReply);
255
+
256
+ const submitOnCmdEnter = (textarea, targetForm) => {
257
+ textarea.addEventListener("keydown", (event) => {
258
+ if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
259
+ event.preventDefault();
260
+ targetForm.requestSubmit();
261
+ }
262
+ });
263
+ };
264
+
265
+ if (form) submitOnCmdEnter(body, form);
266
+
267
+ const menus = document.querySelectorAll("[data-comment-menu]");
268
+
269
+ document.addEventListener("click", (event) => {
270
+ menus.forEach((menu) => {
271
+ if (!menu.contains(event.target)) menu.removeAttribute("open");
272
+ });
273
+ });
274
+
275
+ document.querySelectorAll("[data-edit-comment]").forEach((button) => {
276
+ const row = button.closest("[id^='comment-']");
277
+ const text = row.querySelector("[data-comment-body]");
278
+ const editForm = row.querySelector("[data-edit-form]");
279
+ const textarea = editForm.querySelector("textarea");
280
+
281
+ const toggle = (editing) => {
282
+ text.classList.toggle("hidden", editing);
283
+ editForm.classList.toggle("hidden", !editing);
284
+ };
285
+
286
+ button.addEventListener("click", () => {
287
+ button.closest("details").removeAttribute("open");
288
+ toggle(true);
289
+ textarea.focus();
290
+ textarea.setSelectionRange(textarea.value.length, textarea.value.length);
291
+ });
292
+
293
+ editForm.querySelector("[data-cancel-edit]").addEventListener("click", () => {
294
+ textarea.value = textarea.defaultValue;
295
+ textarea.dispatchEvent(new Event("input"));
296
+ toggle(false);
297
+ });
298
+
299
+ submitOnCmdEnter(textarea, editForm);
300
+ });
301
+
302
+ // Typing "@" opens the editors picker. The text after it filters them, the
303
+ // arrow keys move through them and Enter or Tab writes "@Name". The names
304
+ // are sent as "@editor-<id>", from which the server takes the mentions.
305
+ const picker = document.getElementById("mention-picker");
306
+ const mentionFields = document.querySelectorAll("[data-mention-field] textarea");
307
+
308
+ if (mentionFields.length) {
309
+ const options = picker ? [...picker.querySelectorAll("[data-mention-option]")] : [];
310
+ const empty = picker?.querySelector("[data-mention-empty]");
311
+ let field = null;
312
+ let at = -1;
313
+ let active = null;
314
+
315
+ const visibleOptions = () => options.filter((option) => !option.classList.contains("hidden"));
316
+
317
+ const setActive = (option) => {
318
+ active = option || null;
319
+ options.forEach((item) => item.setAttribute("aria-selected", String(item === active)));
320
+ field?.setAttribute("aria-activedescendant", active ? active.id : "");
321
+ if (!active) return;
322
+
323
+ // Scrolls only the list, the page stays where it is.
324
+ const list = active.parentElement;
325
+ if (active.offsetTop < list.scrollTop) {
326
+ list.scrollTop = active.offsetTop;
327
+ } else if (active.offsetTop + active.offsetHeight > list.scrollTop + list.clientHeight) {
328
+ list.scrollTop = active.offsetTop + active.offsetHeight - list.clientHeight;
329
+ }
330
+ };
331
+
332
+ // The styles which lay out the text of a textarea.
333
+ const textProperties = ["boxSizing", "paddingTop", "paddingRight", "paddingBottom", "paddingLeft", "borderTopWidth", "borderRightWidth", "borderBottomWidth", "borderLeftWidth", "fontFamily", "fontSize", "fontWeight", "fontStyle", "letterSpacing", "lineHeight", "textTransform", "wordSpacing", "textIndent", "tabSize"];
334
+
335
+ const copyTextStyles = (textarea, element) => {
336
+ const style = window.getComputedStyle(textarea);
337
+ textProperties.forEach((property) => {
338
+ element.style[property] = style[property];
339
+ });
340
+
341
+ return style;
342
+ };
343
+
344
+ // The position of a character in a textarea, measured on a copy of it.
345
+ const caretCoordinates = (textarea, position) => {
346
+ const mirror = document.createElement("div");
347
+ const style = copyTextStyles(textarea, mirror);
348
+ mirror.style.width = style.width;
349
+ mirror.style.borderStyle = style.borderStyle;
350
+
351
+ Object.assign(mirror.style, {
352
+ position: "absolute",
353
+ visibility: "hidden",
354
+ top: "0",
355
+ left: "-9999px",
356
+ whiteSpace: "pre-wrap",
357
+ overflowWrap: "break-word"
358
+ });
359
+
360
+ mirror.textContent = textarea.value.slice(0, position);
361
+ const marker = document.createElement("span");
362
+ marker.textContent = textarea.value.slice(position) || ".";
363
+ mirror.appendChild(marker);
364
+ document.body.appendChild(mirror);
365
+
366
+ const coordinates = {
367
+ top: marker.offsetTop + parseFloat(style.borderTopWidth) - textarea.scrollTop,
368
+ left: marker.offsetLeft + parseFloat(style.borderLeftWidth) - textarea.scrollLeft,
369
+ height: parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.5
370
+ };
371
+
372
+ mirror.remove();
373
+
374
+ return coordinates;
375
+ };
376
+
377
+ // Below the "@", or above it when there is more room there.
378
+ const place = () => {
379
+ if (!field) return;
380
+
381
+ const caret = caretCoordinates(field, at);
382
+ const offsetTop = field.offsetTop;
383
+ const caretTop = field.getBoundingClientRect().top + caret.top;
384
+ const height = picker.offsetHeight;
385
+ const below = window.innerHeight - (caretTop + caret.height);
386
+ const above = caretTop;
387
+ const top = below < height + 8 && above > below ? caret.top - height - 4 : caret.top + caret.height + 4;
388
+ const maxLeft = Math.max(0, field.parentElement.clientWidth - picker.offsetWidth);
389
+
390
+ picker.style.top = `${offsetTop + top}px`;
391
+ picker.style.left = `${Math.min(Math.max(0, field.offsetLeft + caret.left), maxLeft)}px`;
392
+ };
393
+
394
+ const close = () => {
395
+ picker.classList.add("hidden");
396
+ field?.setAttribute("aria-expanded", "false");
397
+ field?.removeAttribute("aria-activedescendant");
398
+ field = null;
399
+ at = -1;
400
+ active = null;
401
+ };
402
+
403
+ // The text between the "@" and the caret, null when the mention is left.
404
+ const currentQuery = () => {
405
+ const caret = field.selectionStart;
406
+ if (field.value[at] !== "@" || caret <= at || caret !== field.selectionEnd) return null;
407
+
408
+ const query = field.value.slice(at + 1, caret);
409
+ if (query.includes("\n") || query.startsWith(" ")) return null;
410
+
411
+ return query;
412
+ };
413
+
414
+ const refresh = () => {
415
+ if (!field) return;
416
+
417
+ const query = currentQuery();
418
+ if (query === null) return close();
419
+
420
+ const term = query.trim().toLowerCase();
421
+ options.forEach((option) => option.classList.toggle("hidden", Boolean(term) && !option.dataset.search.includes(term)));
422
+
423
+ const shown = visibleOptions();
424
+
425
+ // A space after a name nobody has ends the mention.
426
+ if (!shown.length && query.endsWith(" ")) return close();
427
+
428
+ empty.classList.toggle("hidden", shown.length > 0);
429
+ if (!shown.includes(active)) setActive(shown[0]);
430
+ place();
431
+ };
432
+
433
+ const open = (textarea, index) => {
434
+ if (!picker) return;
435
+
436
+ field = textarea;
437
+ at = index;
438
+ textarea.parentElement.appendChild(picker);
439
+ textarea.setAttribute("aria-expanded", "true");
440
+ picker.classList.remove("hidden");
441
+ active = null;
442
+ picker.querySelector("#mention-options").scrollTop = 0;
443
+ refresh();
444
+ };
445
+
446
+ // The editor ids of the mentions in each textarea by their names, from
447
+ // the saved comment and the picked editors.
448
+ const mentionIds = new Map();
449
+ const bodyInputs = new Map();
450
+ const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
451
+
452
+ // Longer names first, so "@Jane Doe" wins over "@Jane".
453
+ const mentionRanges = (textarea) => {
454
+ const ids = mentionIds.get(textarea);
455
+ const names = [...ids.keys()].sort((a, b) => b.length - a.length);
456
+ if (!names.length) return [];
457
+
458
+ const pattern = new RegExp(`(^|[^\\p{L}\\p{N}_@/])@(${names.map(escapeRegExp).join("|")})(?![\\p{L}\\p{N}_])`, "gu");
459
+
460
+ return [...textarea.value.matchAll(pattern)].map((match) => {
461
+ const start = match.index + match[1].length;
462
+
463
+ return {
464
+ start,
465
+ end: start + match[2].length + 1,
466
+ id: ids.get(match[2])
467
+ };
468
+ });
469
+ };
470
+
471
+ // The text which is sent, with the mentions written as "@editor-<id>".
472
+ const serializeMentions = (textarea) => {
473
+ const text = textarea.value;
474
+ let body = "";
475
+ let last = 0;
476
+
477
+ mentionRanges(textarea).forEach(({ start, end, id }) => {
478
+ body += `${text.slice(last, start)}@editor-${id}`;
479
+ last = end;
480
+ });
481
+
482
+ bodyInputs.get(textarea).value = body + text.slice(last);
483
+ };
484
+
485
+ const escapeHtml = (value) => value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
486
+ const backdrops = new Map();
487
+
488
+ const renderMentions = (textarea) => {
489
+ const backdrop = backdrops.get(textarea);
490
+ const text = textarea.value;
491
+ let html = "";
492
+ let last = 0;
493
+
494
+ mentionRanges(textarea).forEach(({ start, end }) => {
495
+ html += `${escapeHtml(text.slice(last, start))}<mark>${escapeHtml(text.slice(start, end))}</mark>`;
496
+ last = end;
497
+ });
498
+
499
+ // A trailing line break takes up space only with text after it.
500
+ backdrop.innerHTML = `${html}${escapeHtml(text.slice(last))} `;
501
+ backdrop.scrollTop = textarea.scrollTop;
502
+ };
503
+
504
+ const syncBackdrop = (textarea) => {
505
+ const backdrop = backdrops.get(textarea);
506
+ const style = copyTextStyles(textarea, backdrop);
507
+ const scrollbar = textarea.offsetWidth - textarea.clientWidth - parseFloat(style.borderLeftWidth) - parseFloat(style.borderRightWidth);
508
+
509
+ Object.assign(backdrop.style, {
510
+ top: `${textarea.offsetTop}px`,
511
+ left: `${textarea.offsetLeft}px`,
512
+ width: `${textarea.offsetWidth}px`,
513
+ height: `${textarea.offsetHeight}px`,
514
+ paddingRight: `${parseFloat(style.paddingRight) + scrollbar}px`,
515
+ borderColor: "transparent"
516
+ });
517
+ backdrop.scrollTop = textarea.scrollTop;
518
+ };
519
+
520
+ // Resized by the user, or shown with its edit form.
521
+ const resizeObserver = new ResizeObserver((entries) => entries.forEach(({ target }) => syncBackdrop(target)));
522
+
523
+ // Edits through execCommand stay in the undo history of the textarea.
524
+ const replaceText = (textarea, start, end, text) => {
525
+ textarea.focus();
526
+ textarea.setSelectionRange(start, end);
527
+
528
+ const command = text ? document.execCommand("insertText", false, text) : document.execCommand("delete");
529
+ if (!command) {
530
+ textarea.setRangeText(text, start, end, "end");
531
+ textarea.dispatchEvent(new Event("input"));
532
+ }
533
+ };
534
+
535
+ const choose = (option) => {
536
+ const textarea = field;
537
+ const start = at;
538
+ const end = textarea.selectionStart;
539
+
540
+ mentionIds.get(textarea).set(option.dataset.mention, option.dataset.editorId);
541
+ close();
542
+ replaceText(textarea, start, end, `@${option.dataset.mention} `);
543
+ };
544
+
545
+ mentionFields.forEach((textarea) => {
546
+ textarea.setAttribute("aria-autocomplete", "list");
547
+ textarea.setAttribute("aria-controls", "mention-options");
548
+ textarea.setAttribute("aria-expanded", "false");
549
+
550
+ const backdrop = document.createElement("div");
551
+ backdrop.className = "mention-backdrop";
552
+ backdrop.setAttribute("aria-hidden", "true");
553
+ textarea.after(backdrop);
554
+ textarea.classList.add("mention-input");
555
+ backdrops.set(textarea, backdrop);
556
+
557
+ // The textarea keeps the names, the hidden input sends the ids.
558
+ const bodyInput = document.createElement("input");
559
+ bodyInput.type = "hidden";
560
+ bodyInput.name = textarea.name;
561
+ textarea.removeAttribute("name");
562
+ backdrop.after(bodyInput);
563
+ bodyInputs.set(textarea, bodyInput);
564
+ mentionIds.set(textarea, new Map(Object.entries(JSON.parse(textarea.dataset.mentions || "{}"))));
565
+
566
+ renderMentions(textarea);
567
+ serializeMentions(textarea);
568
+ resizeObserver.observe(textarea);
569
+
570
+ textarea.addEventListener("input", (event) => {
571
+ renderMentions(textarea);
572
+ serializeMentions(textarea);
573
+
574
+ if (field === textarea) return refresh();
575
+ if (event.inputType !== "insertText" || event.data !== "@") return;
576
+
577
+ // Only at the start of a word, so emails don't open it.
578
+ const index = textarea.selectionStart - 1;
579
+ if (index > 0 && !/[\s(]/.test(textarea.value[index - 1])) return;
580
+
581
+ open(textarea, index);
582
+ });
583
+
584
+ textarea.addEventListener("keydown", (event) => {
585
+ if (field !== textarea || event.metaKey || event.ctrlKey || event.altKey) return;
586
+
587
+ const shown = visibleOptions();
588
+
589
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
590
+ event.preventDefault();
591
+ if (!shown.length) return;
592
+
593
+ const step = event.key === "ArrowDown" ? 1 : -1;
594
+ const position = shown.indexOf(active);
595
+ setActive(shown[(position + step + shown.length) % shown.length]);
596
+ } else if ((event.key === "Enter" || event.key === "Tab") && active) {
597
+ event.preventDefault();
598
+ choose(active);
599
+ } else if (event.key === "Escape") {
600
+ event.preventDefault();
601
+ close();
602
+ }
603
+ });
604
+
605
+ // Deleting into a mention deletes all of it.
606
+ textarea.addEventListener("keydown", (event) => {
607
+ if (field === textarea || event.metaKey || event.ctrlKey || event.altKey) return;
608
+ if (event.key !== "Backspace" && event.key !== "Delete") return;
609
+
610
+ const caret = textarea.selectionStart;
611
+ if (caret !== textarea.selectionEnd) return;
612
+
613
+ const range = mentionRanges(textarea).find(({ start, end }) => event.key === "Backspace" ? start < caret && caret <= end : start <= caret && caret < end);
614
+ if (!range) return;
615
+
616
+ event.preventDefault();
617
+ replaceText(textarea, range.start, range.end, "");
618
+ });
619
+
620
+ // Moving the caret out of the mention closes the picker.
621
+ textarea.addEventListener("keyup", (event) => {
622
+ if (field === textarea && ["ArrowLeft", "ArrowRight", "Home", "End", "PageUp", "PageDown"].includes(event.key)) refresh();
623
+ });
624
+ textarea.addEventListener("click", () => field === textarea && refresh());
625
+ textarea.addEventListener("scroll", () => {
626
+ backdrop.scrollTop = textarea.scrollTop;
627
+ if (field === textarea) place();
628
+ });
629
+ textarea.addEventListener("blur", () => field === textarea && close());
630
+ });
631
+
632
+ options.forEach((option) => {
633
+ // Keeps the focus in the textarea.
634
+ option.addEventListener("mousedown", (event) => event.preventDefault());
635
+ option.addEventListener("mousemove", () => {
636
+ if (active !== option) setActive(option);
637
+ });
638
+ option.addEventListener("click", () => field && choose(option));
639
+ });
640
+
641
+ window.addEventListener("resize", place);
642
+ window.addEventListener("scroll", place, {
643
+ passive: true
644
+ });
645
+ }
646
+
647
+ const highlight = (target) => {
648
+ target.classList.add("bg-raketa-primary/10");
649
+ setTimeout(() => target.classList.remove("bg-raketa-primary/10"), 1500);
650
+ };
651
+
652
+ document.querySelectorAll("[data-scroll-to]").forEach((button) => {
653
+ button.addEventListener("click", () => {
654
+ const target = document.getElementById(`comment-${button.dataset.scrollTo}`);
655
+ if (!target) return;
656
+
657
+ history.pushState(null, "", `#${target.id}`);
658
+ target.scrollIntoView({
659
+ behavior: "smooth",
660
+ block: "center"
661
+ });
662
+ highlight(target);
663
+ });
664
+ });
665
+
666
+ // Opened from a copied comment link.
667
+ const linked = /^#comment-\d+$/.test(window.location.hash) && document.querySelector(window.location.hash);
668
+ if (linked) {
669
+ linked.scrollIntoView({
670
+ block: "center"
671
+ });
672
+ highlight(linked);
673
+ }
674
+
675
+ document.querySelectorAll("[data-copy-link]").forEach((button) => {
676
+ const copied = button.nextElementSibling;
677
+
678
+ button.addEventListener("click", async () => {
679
+ const url = new URL(window.location.href);
680
+ url.searchParams.delete("comment_error");
681
+ url.hash = `comment-${button.dataset.copyLink}`;
682
+
683
+ try {
684
+ await navigator.clipboard.writeText(url.toString());
685
+ } catch (e) {
686
+ // The clipboard API needs a secure context.
687
+ window.prompt("Copy the link to the comment", url.toString());
688
+ return;
689
+ }
690
+
691
+ copied.classList.remove("hidden");
692
+ setTimeout(() => copied.classList.add("hidden"), 1500);
693
+ });
694
+ });
695
+
696
+ // Today: the time. The last 7 days: the weekday. Older: the date and time.
697
+ const formatCommentTime = (date, now, locale, dateTimeFormat) => {
698
+ const startOfDay = (value) => new Date(value.getFullYear(), value.getMonth(), value.getDate());
699
+ const days = Math.round((startOfDay(now) - startOfDay(date)) / 86400000);
700
+
701
+ if (days <= 0) return date.toLocaleTimeString(locale, {
702
+ timeStyle: "short"
703
+ });
704
+ if (days < 7) return date.toLocaleDateString(locale, {
705
+ weekday: "short"
706
+ });
707
+
708
+ return date.toLocaleString(locale, dateTimeFormat);
709
+ };
710
+
711
+ // The layout defines the locale and format of the dates after this script.
712
+ document.addEventListener("DOMContentLoaded", () => {
713
+ const locale = typeof TIME_LOCALE !== "undefined" ? TIME_LOCALE : "en-GB";
714
+ const dateTimeFormat = typeof TIME_FORMAT !== "undefined" ? TIME_FORMAT : {
715
+ dateStyle: "short",
716
+ timeStyle: "short"
717
+ };
718
+ const now = new Date();
719
+
720
+ document.querySelectorAll("time[data-comment-datetime]").forEach((item) => {
721
+ const date = new Date(item.getAttribute("datetime"));
722
+ if (Number.isNaN(date.getTime())) return;
723
+
724
+ item.textContent = formatCommentTime(date, now, locale, dateTimeFormat);
725
+ item.title = date.toLocaleString(locale, dateTimeFormat);
726
+ });
727
+ });
728
+ })();
729
+ </script>