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.
- checksums.yaml +4 -4
- data/README.md +1 -0
- data/app/assets/stylesheets/collavre/actiontext.css +251 -90
- data/app/assets/stylesheets/collavre/code_highlight.css +7 -201
- data/app/assets/stylesheets/collavre/comments_popup.css +118 -61
- data/app/assets/stylesheets/collavre/creatives.css +11 -2
- data/app/assets/stylesheets/collavre/modal_dialog.css +32 -0
- data/app/assets/stylesheets/collavre/tables.css +91 -0
- data/app/channels/collavre/inbox_badge_channel.rb +30 -0
- data/app/controllers/collavre/api/v1/mobile/agent_events_controller.rb +224 -0
- data/app/controllers/collavre/api/v1/mobile/base_controller.rb +95 -0
- data/app/controllers/collavre/api/v1/mobile/devices_controller.rb +31 -0
- data/app/controllers/collavre/api/v1/mobile/voice_commands_controller.rb +25 -0
- data/app/controllers/collavre/creatives_controller.rb +16 -5
- data/app/controllers/collavre/tasks_controller.rb +13 -4
- data/app/controllers/collavre/topics_controller.rb +49 -1
- data/app/controllers/collavre/typo_corrections_controller.rb +39 -0
- data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +16 -1
- data/app/controllers/concerns/collavre/users_controller/registration.rb +41 -1
- data/app/helpers/collavre/application_helper.rb +1 -0
- data/app/javascript/collavre.js +2 -0
- data/app/javascript/components/ImageResizer.jsx +9 -3
- data/app/javascript/components/InlineLexicalEditor.jsx +155 -38
- data/app/javascript/components/creative_tree_row.js +20 -3
- data/app/javascript/components/plugins/list_tab_indent_plugin.jsx +16 -0
- data/app/javascript/components/plugins/table_hover_actions_plugin.jsx +405 -0
- data/app/javascript/controllers/__tests__/inbox_badge_controller.test.js +73 -0
- data/app/javascript/controllers/comment_controller.js +5 -4
- data/app/javascript/controllers/comment_version_controller.js +2 -1
- data/app/javascript/controllers/comments/__tests__/form_controller_double_submit.test.js +159 -0
- data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +3 -2
- data/app/javascript/controllers/comments/__tests__/topics_controller_delete.test.js +94 -0
- data/app/javascript/controllers/comments/form_controller.js +21 -5
- data/app/javascript/controllers/comments/list_controller.js +18 -17
- data/app/javascript/controllers/comments/presence_controller.js +2 -1
- data/app/javascript/controllers/comments/topics_controller.js +14 -8
- data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +150 -0
- data/app/javascript/controllers/creatives/import_controller.js +2 -1
- data/app/javascript/controllers/creatives/select_mode_controller.js +2 -1
- data/app/javascript/controllers/creatives/tree_controller.js +142 -1
- data/app/javascript/controllers/image_lightbox_controller.js +2 -1
- data/app/javascript/controllers/inbox_badge_controller.js +33 -0
- data/app/javascript/controllers/index.js +4 -1
- data/app/javascript/controllers/share_modal_controller.js +4 -3
- data/app/javascript/controllers/topic_search_controller.js +2 -1
- data/app/javascript/creatives/drag_drop/event_handlers.js +14 -5
- data/app/javascript/creatives/topic_move_members_popup.js +156 -0
- data/app/javascript/creatives/tree_renderer.js +11 -0
- data/app/javascript/lib/__tests__/turbo_confirm.test.js +81 -0
- data/app/javascript/lib/__tests__/typo_correction.test.js +192 -0
- data/app/javascript/lib/api/__tests__/api_error.test.js +96 -0
- data/app/javascript/lib/api/__tests__/queue_manager.test.js +88 -1
- data/app/javascript/lib/api/api_error.js +108 -0
- data/app/javascript/lib/api/queue_manager.js +38 -4
- data/app/javascript/lib/common_popup.js +18 -5
- data/app/javascript/lib/editor/__tests__/code_edit_view_token_parity.test.js +121 -0
- data/app/javascript/lib/editor/__tests__/code_language_roundtrip.test.js +152 -0
- data/app/javascript/lib/editor/__tests__/code_languages.test.js +93 -0
- data/app/javascript/lib/editor/code_languages.js +173 -0
- data/app/javascript/lib/editor/code_token_theme.js +41 -0
- data/app/javascript/lib/lexical/__tests__/image_focus.test.js +139 -0
- data/app/javascript/lib/lexical/__tests__/list_tab_indent.test.js +633 -0
- data/app/javascript/lib/lexical/__tests__/markdown_serialize.test.js +627 -0
- data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +20 -1
- data/app/javascript/lib/lexical/__tests__/selection_boundary.test.js +88 -0
- data/app/javascript/lib/lexical/__tests__/table_transformer.test.js +163 -0
- data/app/javascript/lib/lexical/__tests__/trailing_paragraph.test.js +104 -0
- data/app/javascript/lib/lexical/list_tab_indent.js +210 -0
- data/app/javascript/lib/lexical/markdown_serialize.js +320 -0
- data/app/javascript/lib/lexical/selection_boundary.js +58 -0
- data/app/javascript/lib/lexical/table_transformer.js +182 -0
- data/app/javascript/lib/lexical/trailing_paragraph.js +29 -0
- data/app/javascript/lib/turbo_confirm.js +46 -0
- data/app/javascript/lib/typo_correction.js +146 -0
- data/app/javascript/lib/utils/__tests__/confirm_dialog.test.js +88 -0
- data/app/javascript/lib/utils/__tests__/dialog.test.js +92 -0
- data/app/javascript/lib/utils/__tests__/markdown.test.js +153 -0
- data/app/javascript/lib/utils/__tests__/sanitize_description.test.js +68 -0
- data/app/javascript/lib/utils/__tests__/table_download.test.js +93 -0
- data/app/javascript/lib/utils/confirm_dialog.js +10 -0
- data/app/javascript/lib/utils/dialog.js +300 -0
- data/app/javascript/lib/utils/markdown.js +154 -67
- data/app/javascript/lib/utils/sanitize_description.js +31 -0
- data/app/javascript/lib/utils/table_download.js +15 -0
- data/app/javascript/modules/__tests__/typo_corrector.test.js +365 -0
- data/app/javascript/modules/creative_row_editor.js +110 -70
- data/app/javascript/modules/export_to_markdown.js +2 -1
- data/app/javascript/modules/lexical_inline_editor.jsx +2 -1
- data/app/javascript/modules/slide_view.js +11 -2
- data/app/javascript/modules/typo_corrector.js +534 -0
- data/app/jobs/collavre/ai_agent_job.rb +7 -4
- data/app/jobs/collavre/compress_job.rb +6 -2
- data/app/models/collavre/comment/broadcastable.rb +46 -7
- data/app/models/collavre/comment/notifiable.rb +14 -4
- data/app/models/collavre/comment.rb +79 -31
- data/app/models/collavre/creative/describable.rb +89 -10
- data/app/models/collavre/task.rb +15 -0
- data/app/models/collavre/user.rb +57 -1
- data/app/services/collavre/ai_client.rb +28 -10
- data/app/services/collavre/auto_theme_generator.rb +1 -1
- data/app/services/collavre/creatives/index_query.rb +85 -16
- data/app/services/collavre/creatives/tree_builder.rb +2 -1
- data/app/services/collavre/gemini_parent_recommender.rb +1 -1
- data/app/services/collavre/inbox_reply_service.rb +5 -0
- data/app/services/collavre/markdown_converter.rb +13 -3
- data/app/services/collavre/mobile/event_summarizer.rb +40 -0
- data/app/services/collavre/orchestration/agent_orchestrator.rb +33 -7
- data/app/services/collavre/orchestration/arbiter.rb +16 -0
- data/app/services/collavre/orchestration/matcher.rb +79 -4
- data/app/services/collavre/orchestration/policy_resolver.rb +4 -3
- data/app/services/collavre/orchestration/stuck_detector.rb +141 -34
- data/app/services/collavre/tools/creative_batch_service.rb +3 -2
- data/app/services/collavre/tools/creative_create_service.rb +8 -8
- data/app/services/collavre/tools/creative_update_service.rb +23 -8
- data/app/services/collavre/typo_corrector.rb +188 -0
- data/app/views/collavre/comments/_comment.html.erb +5 -0
- data/app/views/collavre/comments/_comments_popup.html.erb +14 -1
- data/app/views/collavre/creatives/_inline_edit_form.html.erb +1 -0
- data/app/views/collavre/creatives/_topic_move_members_modal.html.erb +42 -0
- data/app/views/collavre/creatives/index.html.erb +14 -1
- data/app/views/collavre/creatives/slide_view.html.erb +1 -1
- data/app/views/collavre/users/show.html.erb +3 -0
- data/app/views/collavre/users/typo_correction.html.erb +50 -0
- data/app/views/layouts/collavre/slide.html.erb +1 -0
- data/config/locales/comments.en.yml +15 -0
- data/config/locales/comments.ko.yml +15 -0
- data/config/locales/integrations.en.yml +1 -1
- data/config/locales/integrations.ko.yml +1 -1
- data/config/locales/mobile.en.yml +16 -0
- data/config/locales/mobile.ko.yml +16 -0
- data/config/locales/orchestration.en.yml +1 -0
- data/config/locales/orchestration.ko.yml +1 -0
- data/config/locales/users.en.yml +15 -0
- data/config/locales/users.ko.yml +15 -0
- data/config/routes.rb +13 -0
- data/db/migrate/20260612000000_add_topic_concurrency_defer_to_comments.rb +38 -0
- data/db/migrate/20260617090000_add_typo_correction_settings_to_users.rb +18 -0
- data/db/seeds.rb +51 -0
- data/lib/collavre/version.rb +1 -1
- data/lib/generators/collavre/install/install_generator.rb +1 -0
- metadata +55 -2
- 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
|
-
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
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
|
+
})
|