collavre 0.22.0 → 0.23.0

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 (142) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -0
  3. data/app/assets/stylesheets/collavre/actiontext.css +251 -90
  4. data/app/assets/stylesheets/collavre/code_highlight.css +7 -201
  5. data/app/assets/stylesheets/collavre/comments_popup.css +118 -61
  6. data/app/assets/stylesheets/collavre/creatives.css +11 -2
  7. data/app/assets/stylesheets/collavre/modal_dialog.css +32 -0
  8. data/app/assets/stylesheets/collavre/tables.css +91 -0
  9. data/app/channels/collavre/inbox_badge_channel.rb +30 -0
  10. data/app/controllers/collavre/api/v1/mobile/agent_events_controller.rb +224 -0
  11. data/app/controllers/collavre/api/v1/mobile/base_controller.rb +95 -0
  12. data/app/controllers/collavre/api/v1/mobile/devices_controller.rb +31 -0
  13. data/app/controllers/collavre/api/v1/mobile/voice_commands_controller.rb +25 -0
  14. data/app/controllers/collavre/creatives_controller.rb +16 -5
  15. data/app/controllers/collavre/tasks_controller.rb +13 -4
  16. data/app/controllers/collavre/topics_controller.rb +49 -1
  17. data/app/controllers/collavre/typo_corrections_controller.rb +39 -0
  18. data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +16 -1
  19. data/app/controllers/concerns/collavre/users_controller/registration.rb +41 -1
  20. data/app/helpers/collavre/application_helper.rb +1 -0
  21. data/app/javascript/collavre.js +2 -0
  22. data/app/javascript/components/ImageResizer.jsx +9 -3
  23. data/app/javascript/components/InlineLexicalEditor.jsx +155 -38
  24. data/app/javascript/components/creative_tree_row.js +20 -3
  25. data/app/javascript/components/plugins/list_tab_indent_plugin.jsx +16 -0
  26. data/app/javascript/components/plugins/table_hover_actions_plugin.jsx +405 -0
  27. data/app/javascript/controllers/__tests__/inbox_badge_controller.test.js +73 -0
  28. data/app/javascript/controllers/comment_controller.js +5 -4
  29. data/app/javascript/controllers/comment_version_controller.js +2 -1
  30. data/app/javascript/controllers/comments/__tests__/form_controller_double_submit.test.js +159 -0
  31. data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +3 -2
  32. data/app/javascript/controllers/comments/__tests__/topics_controller_delete.test.js +94 -0
  33. data/app/javascript/controllers/comments/form_controller.js +21 -5
  34. data/app/javascript/controllers/comments/list_controller.js +18 -17
  35. data/app/javascript/controllers/comments/presence_controller.js +2 -1
  36. data/app/javascript/controllers/comments/topics_controller.js +14 -8
  37. data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +150 -0
  38. data/app/javascript/controllers/creatives/import_controller.js +2 -1
  39. data/app/javascript/controllers/creatives/select_mode_controller.js +2 -1
  40. data/app/javascript/controllers/creatives/tree_controller.js +142 -1
  41. data/app/javascript/controllers/image_lightbox_controller.js +2 -1
  42. data/app/javascript/controllers/inbox_badge_controller.js +33 -0
  43. data/app/javascript/controllers/index.js +4 -1
  44. data/app/javascript/controllers/share_modal_controller.js +4 -3
  45. data/app/javascript/controllers/topic_search_controller.js +2 -1
  46. data/app/javascript/creatives/drag_drop/event_handlers.js +14 -5
  47. data/app/javascript/creatives/topic_move_members_popup.js +156 -0
  48. data/app/javascript/creatives/tree_renderer.js +11 -0
  49. data/app/javascript/lib/__tests__/turbo_confirm.test.js +81 -0
  50. data/app/javascript/lib/__tests__/typo_correction.test.js +192 -0
  51. data/app/javascript/lib/api/__tests__/api_error.test.js +96 -0
  52. data/app/javascript/lib/api/__tests__/queue_manager.test.js +88 -1
  53. data/app/javascript/lib/api/api_error.js +108 -0
  54. data/app/javascript/lib/api/queue_manager.js +38 -4
  55. data/app/javascript/lib/common_popup.js +18 -5
  56. data/app/javascript/lib/editor/__tests__/code_edit_view_token_parity.test.js +121 -0
  57. data/app/javascript/lib/editor/__tests__/code_language_roundtrip.test.js +152 -0
  58. data/app/javascript/lib/editor/__tests__/code_languages.test.js +93 -0
  59. data/app/javascript/lib/editor/code_languages.js +173 -0
  60. data/app/javascript/lib/editor/code_token_theme.js +41 -0
  61. data/app/javascript/lib/lexical/__tests__/image_focus.test.js +139 -0
  62. data/app/javascript/lib/lexical/__tests__/list_tab_indent.test.js +633 -0
  63. data/app/javascript/lib/lexical/__tests__/markdown_serialize.test.js +627 -0
  64. data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +20 -1
  65. data/app/javascript/lib/lexical/__tests__/selection_boundary.test.js +88 -0
  66. data/app/javascript/lib/lexical/__tests__/table_transformer.test.js +163 -0
  67. data/app/javascript/lib/lexical/__tests__/trailing_paragraph.test.js +104 -0
  68. data/app/javascript/lib/lexical/list_tab_indent.js +210 -0
  69. data/app/javascript/lib/lexical/markdown_serialize.js +320 -0
  70. data/app/javascript/lib/lexical/selection_boundary.js +58 -0
  71. data/app/javascript/lib/lexical/table_transformer.js +182 -0
  72. data/app/javascript/lib/lexical/trailing_paragraph.js +29 -0
  73. data/app/javascript/lib/turbo_confirm.js +46 -0
  74. data/app/javascript/lib/typo_correction.js +146 -0
  75. data/app/javascript/lib/utils/__tests__/confirm_dialog.test.js +88 -0
  76. data/app/javascript/lib/utils/__tests__/dialog.test.js +92 -0
  77. data/app/javascript/lib/utils/__tests__/markdown.test.js +153 -0
  78. data/app/javascript/lib/utils/__tests__/sanitize_description.test.js +68 -0
  79. data/app/javascript/lib/utils/__tests__/table_download.test.js +93 -0
  80. data/app/javascript/lib/utils/confirm_dialog.js +10 -0
  81. data/app/javascript/lib/utils/dialog.js +300 -0
  82. data/app/javascript/lib/utils/markdown.js +154 -67
  83. data/app/javascript/lib/utils/sanitize_description.js +31 -0
  84. data/app/javascript/lib/utils/table_download.js +15 -0
  85. data/app/javascript/modules/__tests__/typo_corrector.test.js +365 -0
  86. data/app/javascript/modules/creative_row_editor.js +110 -70
  87. data/app/javascript/modules/export_to_markdown.js +2 -1
  88. data/app/javascript/modules/lexical_inline_editor.jsx +2 -1
  89. data/app/javascript/modules/slide_view.js +11 -2
  90. data/app/javascript/modules/typo_corrector.js +534 -0
  91. data/app/jobs/collavre/ai_agent_job.rb +7 -4
  92. data/app/jobs/collavre/compress_job.rb +6 -2
  93. data/app/models/collavre/comment/broadcastable.rb +46 -7
  94. data/app/models/collavre/comment/notifiable.rb +14 -4
  95. data/app/models/collavre/comment.rb +79 -31
  96. data/app/models/collavre/creative/describable.rb +89 -10
  97. data/app/models/collavre/task.rb +15 -0
  98. data/app/models/collavre/user.rb +57 -1
  99. data/app/services/collavre/ai_client.rb +28 -10
  100. data/app/services/collavre/auto_theme_generator.rb +1 -1
  101. data/app/services/collavre/creatives/index_query.rb +85 -16
  102. data/app/services/collavre/creatives/tree_builder.rb +2 -1
  103. data/app/services/collavre/gemini_parent_recommender.rb +1 -1
  104. data/app/services/collavre/inbox_reply_service.rb +5 -0
  105. data/app/services/collavre/markdown_converter.rb +13 -3
  106. data/app/services/collavre/mobile/event_summarizer.rb +40 -0
  107. data/app/services/collavre/orchestration/agent_orchestrator.rb +33 -7
  108. data/app/services/collavre/orchestration/arbiter.rb +16 -0
  109. data/app/services/collavre/orchestration/matcher.rb +79 -4
  110. data/app/services/collavre/orchestration/policy_resolver.rb +4 -3
  111. data/app/services/collavre/orchestration/stuck_detector.rb +141 -34
  112. data/app/services/collavre/tools/creative_batch_service.rb +3 -2
  113. data/app/services/collavre/tools/creative_create_service.rb +8 -8
  114. data/app/services/collavre/tools/creative_update_service.rb +23 -8
  115. data/app/services/collavre/typo_corrector.rb +188 -0
  116. data/app/views/collavre/comments/_comment.html.erb +5 -0
  117. data/app/views/collavre/comments/_comments_popup.html.erb +14 -1
  118. data/app/views/collavre/creatives/_inline_edit_form.html.erb +1 -0
  119. data/app/views/collavre/creatives/_topic_move_members_modal.html.erb +42 -0
  120. data/app/views/collavre/creatives/index.html.erb +14 -1
  121. data/app/views/collavre/creatives/slide_view.html.erb +1 -1
  122. data/app/views/collavre/users/show.html.erb +3 -0
  123. data/app/views/collavre/users/typo_correction.html.erb +50 -0
  124. data/app/views/layouts/collavre/slide.html.erb +1 -0
  125. data/config/locales/comments.en.yml +15 -0
  126. data/config/locales/comments.ko.yml +15 -0
  127. data/config/locales/integrations.en.yml +1 -1
  128. data/config/locales/integrations.ko.yml +1 -1
  129. data/config/locales/mobile.en.yml +16 -0
  130. data/config/locales/mobile.ko.yml +16 -0
  131. data/config/locales/orchestration.en.yml +1 -0
  132. data/config/locales/orchestration.ko.yml +1 -0
  133. data/config/locales/users.en.yml +15 -0
  134. data/config/locales/users.ko.yml +15 -0
  135. data/config/routes.rb +13 -0
  136. data/db/migrate/20260612000000_add_topic_concurrency_defer_to_comments.rb +38 -0
  137. data/db/migrate/20260617090000_add_typo_correction_settings_to_users.rb +18 -0
  138. data/db/seeds.rb +51 -0
  139. data/lib/collavre/version.rb +1 -1
  140. data/lib/generators/collavre/install/install_generator.rb +1 -0
  141. metadata +55 -2
  142. data/app/services/collavre/tools/description_normalizable.rb +0 -16
@@ -0,0 +1,405 @@
1
+ import { useEffect, useMemo, useRef, useState } from "react"
2
+ import { createPortal } from "react-dom"
3
+ import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
4
+ import { useLexicalEditable } from "@lexical/react/useLexicalEditable"
5
+ import {
6
+ $deleteTableColumnAtSelection,
7
+ $deleteTableRowAtSelection,
8
+ $getTableAndElementByKey,
9
+ $getTableColumnIndexFromTableCellNode,
10
+ $getTableRowIndexFromTableCellNode,
11
+ $insertTableColumnAtSelection,
12
+ $insertTableRowAtSelection,
13
+ $isTableCellNode,
14
+ $isTableNode,
15
+ getTableElement,
16
+ TableNode
17
+ } from "@lexical/table"
18
+ import { $findMatchingParent, mergeRegister } from "@lexical/utils"
19
+ import { $getNearestNodeFromDOMNode, isHTMLElement } from "lexical"
20
+
21
+ // Cell-boundary action buttons, all driven purely by `mousemove` (never by focus
22
+ // or selection): hover near a table's bottom edge to append a row, near its right
23
+ // edge to append a column, and hover any cell to reveal "×" buttons in the row's
24
+ // left gutter / the column's top gutter that delete that row / column.
25
+ //
26
+ // Delete used to live in a chevron dropdown (TableActionMenuPlugin) that tracked
27
+ // the editor selection and portaled a floating menu. On desktop, clicking the
28
+ // chevron stole focus from the contenteditable, which collapsed the selection out
29
+ // of the editor and made the menu flash-then-close while the chevron bounced — a
30
+ // focus/selection/render-timing race that could not be reproduced headless and
31
+ // survived four positioning fixes. This mousemove-driven approach has none of that
32
+ // machinery (no selection tracking, no $moveMenu, no click-outside, no focus
33
+ // dependency), which is exactly why the add "+" buttons always worked on PC and
34
+ // mobile alike. Ported/extended from the Lexical playground's TableHoverActions.
35
+
36
+ const BUTTON_WIDTH_PX = 20
37
+ const DELETE_BUTTON_PX = 18
38
+
39
+ // Build a CSS selector from a theme class name (mirrors the playground helper).
40
+ function getThemeSelector(getTheme, name) {
41
+ const className = getTheme()?.[name]
42
+ if (typeof className !== "string") {
43
+ throw new Error(`getThemeSelector: required theme property ${name} not defined`)
44
+ }
45
+ return className
46
+ .split(/\s+/g)
47
+ .map((cls) => `.${cls}`)
48
+ .join()
49
+ }
50
+
51
+ // Trailing-edge debounce with a maxWait ceiling and a cancel() method.
52
+ function useDebounce(fn, ms, maxWait) {
53
+ const fnRef = useRef(fn)
54
+ fnRef.current = fn
55
+ return useMemo(() => {
56
+ let timer = null
57
+ let firstCall = 0
58
+ const invoke = (args) => {
59
+ timer = null
60
+ firstCall = 0
61
+ fnRef.current(...args)
62
+ }
63
+ const debounced = (...args) => {
64
+ const now = Date.now()
65
+ if (!timer) firstCall = now
66
+ if (timer) clearTimeout(timer)
67
+ const waitedFor = now - firstCall
68
+ const remainingMax = maxWait != null ? Math.max(0, maxWait - waitedFor) : Infinity
69
+ const delay = Math.min(ms, remainingMax)
70
+ timer = setTimeout(() => invoke(args), delay)
71
+ }
72
+ debounced.cancel = () => {
73
+ if (timer) clearTimeout(timer)
74
+ timer = null
75
+ firstCall = 0
76
+ }
77
+ return debounced
78
+ }, [ms, maxWait])
79
+ }
80
+
81
+ function getMouseInfo(event, getTheme) {
82
+ const target = event.target
83
+ const tableCellClass = getThemeSelector(getTheme, "tableCell")
84
+
85
+ if (isHTMLElement(target)) {
86
+ const tableDOMNode = target.closest(`td${tableCellClass}, th${tableCellClass}`)
87
+
88
+ const isOutside = !(
89
+ tableDOMNode ||
90
+ target.closest(`button${getThemeSelector(getTheme, "tableAddRows")}`) ||
91
+ target.closest(`button${getThemeSelector(getTheme, "tableAddColumns")}`) ||
92
+ target.closest(`button${getThemeSelector(getTheme, "tableDeleteRows")}`) ||
93
+ target.closest(`button${getThemeSelector(getTheme, "tableDeleteColumns")}`) ||
94
+ target.closest("div.TableCellResizer__resizer")
95
+ )
96
+
97
+ return { isOutside, tableDOMNode }
98
+ }
99
+ return { isOutside: true, tableDOMNode: null }
100
+ }
101
+
102
+ function TableHoverActionsContainer({ anchorElem }) {
103
+ const [editor, { getTheme }] = useLexicalComposerContext()
104
+ const isEditable = useLexicalEditable()
105
+ const [isShownRow, setShownRow] = useState(false)
106
+ const [isShownColumn, setShownColumn] = useState(false)
107
+ const [isShownDeleteRow, setShownDeleteRow] = useState(false)
108
+ const [isShownDeleteColumn, setShownDeleteColumn] = useState(false)
109
+ const [shouldListenMouseMove, setShouldListenMouseMove] = useState(false)
110
+ const [position, setPosition] = useState({})
111
+ const [deleteRowPosition, setDeleteRowPosition] = useState({})
112
+ const [deleteColumnPosition, setDeleteColumnPosition] = useState({})
113
+ const tableSetRef = useRef(new Set())
114
+ const tableCellDOMNodeRef = useRef(null)
115
+
116
+ const hideAll = () => {
117
+ setShownRow(false)
118
+ setShownColumn(false)
119
+ setShownDeleteRow(false)
120
+ setShownDeleteColumn(false)
121
+ }
122
+
123
+ const debouncedOnMouseMove = useDebounce(
124
+ (event) => {
125
+ const { isOutside, tableDOMNode } = getMouseInfo(event, getTheme)
126
+
127
+ if (isOutside) {
128
+ hideAll()
129
+ return
130
+ }
131
+
132
+ if (!tableDOMNode) return
133
+
134
+ tableCellDOMNodeRef.current = tableDOMNode
135
+
136
+ let hoveredRowNode = null
137
+ let hoveredColumnNode = null
138
+ let tableDOMElement = null
139
+ let canDeleteRow = false
140
+ let canDeleteColumn = false
141
+
142
+ editor.getEditorState().read(
143
+ () => {
144
+ const maybeTableCell = $getNearestNodeFromDOMNode(tableDOMNode)
145
+
146
+ if ($isTableCellNode(maybeTableCell)) {
147
+ const table = $findMatchingParent(maybeTableCell, (node) => $isTableNode(node))
148
+ if (!$isTableNode(table)) return
149
+
150
+ tableDOMElement = getTableElement(table, editor.getElementByKey(table.getKey()))
151
+
152
+ if (tableDOMElement) {
153
+ const rowCount = table.getChildrenSize()
154
+ const colCount = table.getChildAtIndex(0)?.getChildrenSize()
155
+
156
+ const rowIndex = $getTableRowIndexFromTableCellNode(maybeTableCell)
157
+ const colIndex = $getTableColumnIndexFromTableCellNode(maybeTableCell)
158
+
159
+ if (rowIndex === rowCount - 1) {
160
+ hoveredRowNode = maybeTableCell
161
+ } else if (colIndex === colCount - 1) {
162
+ hoveredColumnNode = maybeTableCell
163
+ }
164
+
165
+ // Deleting the only row/column would leave a degenerate table, so
166
+ // the gutter "×" only appears when more than one remains.
167
+ canDeleteRow = rowCount > 1
168
+ canDeleteColumn = (colCount ?? 0) > 1
169
+ }
170
+ }
171
+ },
172
+ { editor }
173
+ )
174
+
175
+ if (!tableDOMElement) return
176
+
177
+ const {
178
+ width: tableElemWidth,
179
+ y: tableElemY,
180
+ right: tableElemRight,
181
+ left: tableElemLeft,
182
+ bottom: tableElemBottom,
183
+ height: tableElemHeight
184
+ } = tableDOMElement.getBoundingClientRect()
185
+
186
+ const parentElement = tableDOMElement.parentElement
187
+ let tableHasScroll = false
188
+ if (parentElement && parentElement.classList.contains("lexical-table-wrapper")) {
189
+ tableHasScroll = parentElement.scrollWidth > parentElement.clientWidth
190
+ }
191
+ const { y: editorElemY, left: editorElemLeft } = anchorElem.getBoundingClientRect()
192
+
193
+ // ----- Append "+" bars (last row / last column), unchanged behaviour. -----
194
+ if (hoveredRowNode) {
195
+ const isMac = /^mac/i.test(navigator.platform)
196
+
197
+ setShownColumn(false)
198
+ setShownRow(true)
199
+ setPosition({
200
+ height: BUTTON_WIDTH_PX,
201
+ left:
202
+ tableHasScroll && parentElement
203
+ ? parentElement.offsetLeft
204
+ : tableElemLeft - editorElemLeft,
205
+ top: tableElemBottom - editorElemY + (tableHasScroll && !isMac ? 16 : 5),
206
+ width:
207
+ tableHasScroll && parentElement ? parentElement.offsetWidth : tableElemWidth
208
+ })
209
+ } else if (hoveredColumnNode) {
210
+ setShownColumn(true)
211
+ setShownRow(false)
212
+ setPosition({
213
+ height: tableElemHeight,
214
+ left: tableElemRight - editorElemLeft + 5,
215
+ top: tableElemY - editorElemY,
216
+ width: BUTTON_WIDTH_PX
217
+ })
218
+ } else {
219
+ setShownRow(false)
220
+ setShownColumn(false)
221
+ }
222
+
223
+ // ----- Delete "×" buttons for the hovered cell's own row and column. -----
224
+ const cellRect = tableDOMNode.getBoundingClientRect()
225
+
226
+ if (canDeleteRow) {
227
+ // Vertical strip in the row's left gutter, just outside the table edge.
228
+ setShownDeleteRow(true)
229
+ setDeleteRowPosition({
230
+ top: cellRect.top - editorElemY,
231
+ height: cellRect.height,
232
+ left: Math.max(0, tableElemLeft - editorElemLeft - DELETE_BUTTON_PX - 4),
233
+ width: DELETE_BUTTON_PX
234
+ })
235
+ } else {
236
+ setShownDeleteRow(false)
237
+ }
238
+
239
+ if (canDeleteColumn) {
240
+ // Horizontal strip in the column's top gutter, just above the table edge.
241
+ setShownDeleteColumn(true)
242
+ setDeleteColumnPosition({
243
+ left: cellRect.left - editorElemLeft,
244
+ width: cellRect.width,
245
+ top: Math.max(0, tableElemY - editorElemY - DELETE_BUTTON_PX - 4),
246
+ height: DELETE_BUTTON_PX
247
+ })
248
+ } else {
249
+ setShownDeleteColumn(false)
250
+ }
251
+ },
252
+ 50,
253
+ 250
254
+ )
255
+
256
+ // Hide the buttons whenever a table resizes so a button can't end up overlapping
257
+ // a row/column after text entry grows a cell.
258
+ const tableResizeObserver = useMemo(() => {
259
+ return new ResizeObserver(() => {
260
+ hideAll()
261
+ })
262
+ }, [])
263
+
264
+ useEffect(() => {
265
+ if (!shouldListenMouseMove) return
266
+
267
+ document.addEventListener("mousemove", debouncedOnMouseMove)
268
+
269
+ return () => {
270
+ hideAll()
271
+ debouncedOnMouseMove.cancel()
272
+ document.removeEventListener("mousemove", debouncedOnMouseMove)
273
+ }
274
+ }, [shouldListenMouseMove, debouncedOnMouseMove])
275
+
276
+ useEffect(() => {
277
+ const unregister = mergeRegister(
278
+ editor.registerMutationListener(
279
+ TableNode,
280
+ (mutations) => {
281
+ editor.getEditorState().read(
282
+ () => {
283
+ let resetObserver = false
284
+ for (const [key, type] of mutations) {
285
+ if (type === "created") {
286
+ tableSetRef.current.add(key)
287
+ resetObserver = true
288
+ } else if (type === "destroyed") {
289
+ tableSetRef.current.delete(key)
290
+ resetObserver = true
291
+ }
292
+ }
293
+ if (resetObserver) {
294
+ tableResizeObserver.disconnect()
295
+ for (const tableKey of tableSetRef.current) {
296
+ const { tableElement } = $getTableAndElementByKey(tableKey)
297
+ // Guard: the key may be destroyed between the mutation batch
298
+ // and this read, leaving no element to observe.
299
+ if (tableElement) tableResizeObserver.observe(tableElement)
300
+ }
301
+ setShouldListenMouseMove(tableSetRef.current.size > 0)
302
+ }
303
+ },
304
+ { editor }
305
+ )
306
+ },
307
+ { skipInitialization: false }
308
+ )
309
+ )
310
+ // Disconnect the observer on unmount; mergeRegister only unregisters the
311
+ // mutation listener, so without this the observer leaks table DOM refs
312
+ // across editor open/close cycles.
313
+ return () => {
314
+ unregister()
315
+ tableResizeObserver.disconnect()
316
+ }
317
+ }, [editor, tableResizeObserver])
318
+
319
+ const insertAction = (insertRow) => {
320
+ editor.update(() => {
321
+ if (tableCellDOMNodeRef.current) {
322
+ const maybeTableNode = $getNearestNodeFromDOMNode(tableCellDOMNodeRef.current)
323
+ maybeTableNode?.selectEnd()
324
+ if (insertRow) {
325
+ $insertTableRowAtSelection()
326
+ setShownRow(false)
327
+ } else {
328
+ $insertTableColumnAtSelection()
329
+ setShownColumn(false)
330
+ }
331
+ }
332
+ })
333
+ }
334
+
335
+ const deleteAction = (deleteRow) => {
336
+ editor.update(() => {
337
+ if (tableCellDOMNodeRef.current) {
338
+ const maybeTableCell = $getNearestNodeFromDOMNode(tableCellDOMNodeRef.current)
339
+ if ($isTableCellNode(maybeTableCell)) {
340
+ // Anchor the selection in the hovered cell so the delete targets its
341
+ // row/column (mousemove never moved the editor selection there).
342
+ maybeTableCell.selectEnd()
343
+ if (deleteRow) {
344
+ $deleteTableRowAtSelection()
345
+ setShownDeleteRow(false)
346
+ } else {
347
+ $deleteTableColumnAtSelection()
348
+ setShownDeleteColumn(false)
349
+ }
350
+ }
351
+ }
352
+ })
353
+ }
354
+
355
+ if (!isEditable) return null
356
+
357
+ return (
358
+ <>
359
+ {isShownRow && (
360
+ <button
361
+ type="button"
362
+ aria-label="Add row"
363
+ className={`${getTheme()?.tableAddRows}`}
364
+ style={{ ...position }}
365
+ onClick={() => insertAction(true)}
366
+ />
367
+ )}
368
+ {isShownColumn && (
369
+ <button
370
+ type="button"
371
+ aria-label="Add column"
372
+ className={`${getTheme()?.tableAddColumns}`}
373
+ style={{ ...position }}
374
+ onClick={() => insertAction(false)}
375
+ />
376
+ )}
377
+ {isShownDeleteRow && (
378
+ <button
379
+ type="button"
380
+ aria-label="Delete row"
381
+ className={`${getTheme()?.tableDeleteRows}`}
382
+ style={{ ...deleteRowPosition }}
383
+ onClick={() => deleteAction(true)}
384
+ />
385
+ )}
386
+ {isShownDeleteColumn && (
387
+ <button
388
+ type="button"
389
+ aria-label="Delete column"
390
+ className={`${getTheme()?.tableDeleteColumns}`}
391
+ style={{ ...deleteColumnPosition }}
392
+ onClick={() => deleteAction(false)}
393
+ />
394
+ )}
395
+ </>
396
+ )
397
+ }
398
+
399
+ export default function TableHoverActionsPlugin({ anchorElem = document.body } = {}) {
400
+ const isEditable = useLexicalEditable()
401
+
402
+ return isEditable
403
+ ? createPortal(<TableHoverActionsContainer anchorElem={anchorElem} />, anchorElem)
404
+ : null
405
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import { jest } from '@jest/globals'
6
+
7
+ const createSubscription = jest.fn()
8
+ const renderStreamMessage = jest.fn()
9
+
10
+ jest.unstable_mockModule('../../services/cable', () => ({
11
+ createSubscription,
12
+ }))
13
+
14
+ jest.unstable_mockModule('@hotwired/turbo-rails', () => ({
15
+ Turbo: { renderStreamMessage },
16
+ }))
17
+
18
+ const { Application } = await import('@hotwired/stimulus')
19
+ const InboxBadgeController = (await import('../inbox_badge_controller')).default
20
+
21
+ describe('InboxBadgeController', () => {
22
+ let application
23
+ let container
24
+ let subscription
25
+
26
+ beforeEach(() => {
27
+ subscription = { unsubscribe: jest.fn() }
28
+ createSubscription.mockReset()
29
+ renderStreamMessage.mockReset()
30
+ createSubscription.mockReturnValue(subscription)
31
+
32
+ container = document.createElement('div')
33
+ container.setAttribute('data-controller', 'inbox-badge')
34
+ document.body.appendChild(container)
35
+
36
+ application = Application.start()
37
+ application.register('inbox-badge', InboxBadgeController)
38
+ })
39
+
40
+ afterEach(() => {
41
+ application.stop()
42
+ document.body.innerHTML = ''
43
+ })
44
+
45
+ test('subscribes to the namespaced InboxBadgeChannel on connect', async () => {
46
+ await new Promise((resolve) => setTimeout(resolve, 0))
47
+
48
+ expect(createSubscription).toHaveBeenCalledTimes(1)
49
+ expect(createSubscription).toHaveBeenCalledWith(
50
+ { channel: 'Collavre::InboxBadgeChannel' },
51
+ expect.objectContaining({ received: expect.any(Function) }),
52
+ )
53
+ })
54
+
55
+ test('renders the transmitted badge snapshot through Turbo on its own subscription', async () => {
56
+ await new Promise((resolve) => setTimeout(resolve, 0))
57
+
58
+ const [, callbacks] = createSubscription.mock.calls[0]
59
+ const snapshot = '<turbo-stream action="replace" target="desktop-inbox-badge"></turbo-stream>'
60
+ callbacks.received(snapshot)
61
+
62
+ expect(renderStreamMessage).toHaveBeenCalledWith(snapshot)
63
+ })
64
+
65
+ test('unsubscribes when the controller disconnects', async () => {
66
+ await new Promise((resolve) => setTimeout(resolve, 0))
67
+
68
+ container.remove()
69
+ await new Promise((resolve) => setTimeout(resolve, 0))
70
+
71
+ expect(subscription.unsubscribe).toHaveBeenCalledTimes(1)
72
+ })
73
+ })
@@ -3,6 +3,7 @@ import { renderCommentMarkdown, renderMermaidDiagrams } from '../lib/utils/markd
3
3
  import { addTableDownloadButtons } from '../lib/utils/table_download'
4
4
  import CommonPopup from '../lib/common_popup'
5
5
  import csrfFetch from '../lib/api/csrf_fetch'
6
+ import { alertDialog, confirmDialog } from '../lib/utils/dialog'
6
7
 
7
8
  // Global tracker: persists streaming state across Turbo replacements
8
9
  // (each replacement creates a new controller instance, losing instance state)
@@ -248,7 +249,7 @@ export default class extends Controller {
248
249
  const data = await response.json()
249
250
  this.updateReactionsUI(data)
250
251
  } catch (error) {
251
- alert(error?.message || 'Failed to update reaction')
252
+ alertDialog(error?.message || 'Failed to update reaction')
252
253
  }
253
254
  }
254
255
 
@@ -495,7 +496,7 @@ export default class extends Controller {
495
496
  const creativeId = button.dataset.creativeId
496
497
  const confirmText = button.dataset.confirmText || 'Restore original messages?'
497
498
 
498
- if (!confirm(confirmText)) return
499
+ if (!(await confirmDialog(confirmText, { danger: true }))) return
499
500
 
500
501
  button.disabled = true
501
502
  try {
@@ -511,12 +512,12 @@ export default class extends Controller {
511
512
  // the summary comment (this one) will be destroyed via broadcast too
512
513
  } else {
513
514
  const data = await response.json().catch(() => ({}))
514
- alert(data.error || 'Failed to restore')
515
+ alertDialog(data.error || 'Failed to restore')
515
516
  button.disabled = false
516
517
  }
517
518
  } catch (error) {
518
519
  console.error('Error restoring snapshot:', error)
519
- alert('Failed to restore')
520
+ alertDialog('Failed to restore')
520
521
  button.disabled = false
521
522
  }
522
523
  }
@@ -1,5 +1,6 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
  import { renderCommentMarkdown, renderMermaidDiagrams } from "../lib/utils/markdown"
3
+ import { confirmDialog } from "../lib/utils/dialog"
3
4
 
4
5
  export default class extends Controller {
5
6
  static targets = ["prevBtn", "nextBtn", "indicator", "deleteBtn", "selectBtn"]
@@ -80,7 +81,7 @@ export default class extends Controller {
80
81
  // Can't delete if it's the only version
81
82
  if (versions.length <= 1) return
82
83
 
83
- if (!confirm(this.deleteBtnTarget.dataset.confirmMessage || "Are you sure?")) return
84
+ if (!(await confirmDialog(this.deleteBtnTarget.dataset.confirmMessage || "Are you sure?", { danger: true }))) return
84
85
 
85
86
  const response = await fetch(
86
87
  `${this.versionsUrlValue}/${version.id}`,
@@ -0,0 +1,159 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import { jest } from '@jest/globals'
6
+ import { Application } from '@hotwired/stimulus'
7
+ import FormController from '../form_controller'
8
+
9
+ // Regression harness for: "slow send API + double Enter => message sent twice".
10
+ //
11
+ // Scenario A proves the in-flight guard blocks a second Enter on the same
12
+ // controller instance. Scenario B reproduces the real-world bug: a Turbo/
13
+ // websocket morph reconnects the Stimulus controller mid-send, and (before the
14
+ // fix) connect() reset this.sending, blowing away the guard so an impatient
15
+ // second Enter re-sent the same message.
16
+ describe('FormController - double submit on slow API', () => {
17
+ let application
18
+ let container
19
+ let controller
20
+
21
+ const FIXTURE = `
22
+ <div id="comments-popup"
23
+ data-controller="comments--form"
24
+ data-review-feedback-placeholder="Write feedback for this quote..."
25
+ data-review-summary-placeholder="Overall comment (optional)..."
26
+ data-review-add-quote="+ Add"
27
+ data-review-send="Send review"
28
+ data-review-send-question="Send question"
29
+ data-review-type-review="💬"
30
+ data-review-type-question="❓"
31
+ data-review-type-review-label="Review"
32
+ data-review-type-question-label="Question">
33
+ <form data-comments--form-target="form">
34
+ <input type="hidden" name="comment[quoted_comment_id]" data-comments--form-target="quotedCommentId" value="" />
35
+ <input type="hidden" name="comment[quoted_text]" data-comments--form-target="quotedText" value="" />
36
+ <div data-comments--form-target="quoteIndicator" style="display:none;">
37
+ <span data-comments--form-target="quoteIndicatorText"></span>
38
+ </div>
39
+ <div class="review-quotes-container" data-comments--form-target="reviewQuotesContainer" style="display:none;"></div>
40
+ <textarea data-comments--form-target="textarea" rows="2"></textarea>
41
+ <input type="checkbox" data-comments--form-target="privateCheckbox" />
42
+ <button type="submit" data-comments--form-target="submit">Send</button>
43
+ <button data-comments--form-target="cancel" style="display:none;">Cancel</button>
44
+ <button data-comments--form-target="moveButton" style="display:none;">Move</button>
45
+ <button data-comments--form-target="searchButton" style="display:none;">Search</button>
46
+ <button data-comments--form-target="voiceButton" style="display:none;">Voice</button>
47
+ <input type="file" data-comments--form-target="imageInput" style="display:none;" />
48
+ <button data-comments--form-target="imageButton" style="display:none;">Image</button>
49
+ <div data-comments--form-target="attachmentList"></div>
50
+ </form>
51
+ </div>`
52
+
53
+ beforeEach(async () => {
54
+ const meta = document.createElement('meta')
55
+ meta.name = 'csrf-token'
56
+ meta.content = 'test-token'
57
+ document.head.appendChild(meta)
58
+
59
+ if (!global.DataTransfer) {
60
+ global.DataTransfer = class {
61
+ constructor() {
62
+ this.files = []
63
+ this.items = { add: (file) => this.files.push(file) }
64
+ }
65
+ }
66
+ }
67
+
68
+ container = document.createElement('div')
69
+ container.innerHTML = FIXTURE
70
+ document.body.appendChild(container)
71
+
72
+ application = Application.start()
73
+ application.register('comments--form', FormController)
74
+ await new Promise((r) => setTimeout(r, 50))
75
+ controller = application.getControllerForElementAndIdentifier(
76
+ container.querySelector('#comments-popup'),
77
+ 'comments--form',
78
+ )
79
+ controller.creativeId = '123'
80
+ controller.currentTopicId = '10073'
81
+ })
82
+
83
+ afterEach(() => {
84
+ application.stop()
85
+ document.body.removeChild(container)
86
+ })
87
+
88
+ const pressEnter = (textarea) => {
89
+ textarea.dispatchEvent(
90
+ new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
91
+ )
92
+ }
93
+
94
+ test('SCENARIO A: two Enter presses while a fetch is in-flight => fetch fires only once', () => {
95
+ const fetchSpy = jest.fn(() => new Promise(() => {})) // never resolves: slow API
96
+ global.fetch = fetchSpy
97
+ controller.creativeId = '111'
98
+
99
+ const textarea = controller.textareaTarget
100
+ textarea.value = 'hello world'
101
+
102
+ pressEnter(textarea)
103
+ pressEnter(textarea) // impatient second Enter
104
+
105
+ expect(fetchSpy).toHaveBeenCalledTimes(1)
106
+ })
107
+
108
+ test('SCENARIO B: a controller reconnect (morph) mid-send must NOT re-enable sending', () => {
109
+ const fetchSpy = jest.fn(() => new Promise(() => {})) // never resolves: slow API
110
+ global.fetch = fetchSpy
111
+ controller.creativeId = '222'
112
+
113
+ const textarea = controller.textareaTarget
114
+ textarea.value = 'hello world'
115
+
116
+ pressEnter(textarea)
117
+ expect(fetchSpy).toHaveBeenCalledTimes(1)
118
+
119
+ // A Turbo Stream / websocket broadcast morphs the comments area while the
120
+ // request is still in flight. Stimulus reconnects the controller, which
121
+ // resets the instance-only `this.sending` flag back to false.
122
+ controller.disconnect()
123
+ controller.connect()
124
+ controller.creativeId = '222'
125
+ controller.currentTopicId = '10073'
126
+
127
+ // The morph preserves the user's typed text; the impatient user hits Enter
128
+ // again because the slow API still has not responded.
129
+ controller.textareaTarget.value = 'hello world'
130
+ pressEnter(controller.textareaTarget)
131
+
132
+ // Durable (module-scope) guard must still block the duplicate.
133
+ expect(fetchSpy).toHaveBeenCalledTimes(1)
134
+ })
135
+
136
+ test('SCENARIO C: after the request completes, the next message sends normally', async () => {
137
+ let resolveFetch
138
+ const fetchSpy = jest.fn(
139
+ () => new Promise((resolve) => { resolveFetch = resolve }),
140
+ )
141
+ global.fetch = fetchSpy
142
+ controller.creativeId = '333'
143
+ // Avoid touching the comment list / DOM rendering on success.
144
+ jest.spyOn(controller, 'resetForm').mockImplementation(() => {})
145
+
146
+ const textarea = controller.textareaTarget
147
+ textarea.value = 'first'
148
+ pressEnter(textarea)
149
+ expect(fetchSpy).toHaveBeenCalledTimes(1)
150
+
151
+ // Resolve the first request, then let the promise microtasks (then/finally) run.
152
+ resolveFetch({ ok: true, status: 200, text: () => Promise.resolve('<div></div>') })
153
+ await new Promise((r) => setTimeout(r, 0))
154
+
155
+ controller.textareaTarget.value = 'second'
156
+ pressEnter(controller.textareaTarget)
157
+ expect(fetchSpy).toHaveBeenCalledTimes(2)
158
+ })
159
+ })