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,633 @@
1
+ import {
2
+ createEditor,
3
+ $getRoot,
4
+ $createParagraphNode,
5
+ $createTextNode,
6
+ $createRangeSelection,
7
+ $setSelection,
8
+ $isParagraphNode,
9
+ KEY_TAB_COMMAND
10
+ } from "lexical"
11
+ import { HeadingNode, QuoteNode, registerRichText } from "@lexical/rich-text"
12
+ import {
13
+ ListNode,
14
+ ListItemNode,
15
+ $createListNode,
16
+ $createListItemNode,
17
+ $isListNode,
18
+ $isListItemNode,
19
+ registerList
20
+ } from "@lexical/list"
21
+ import { registerListTabIndentation } from "../list_tab_indent"
22
+
23
+ function buildListEditor() {
24
+ const editor = createEditor({
25
+ namespace: "test",
26
+ onError(error) {
27
+ throw error
28
+ },
29
+ nodes: [HeadingNode, QuoteNode, ListNode, ListItemNode]
30
+ })
31
+ // registerRichText handles INDENT/OUTDENT_CONTENT_COMMAND; registerList turns
32
+ // the resulting indent change into real nested list structure.
33
+ registerRichText(editor)
34
+ registerList(editor)
35
+ registerListTabIndentation(editor)
36
+ return editor
37
+ }
38
+
39
+ function countNestedLists(root) {
40
+ let nested = 0
41
+ const walk = (node) => {
42
+ if (typeof node.getChildren !== "function") return
43
+ node.getChildren().forEach((child) => {
44
+ if ($isListNode(child)) nested += 1
45
+ walk(child)
46
+ })
47
+ }
48
+ walk(root)
49
+ return nested
50
+ }
51
+
52
+ describe("registerListTabIndentation", () => {
53
+ it("nests the current list item on Tab", () => {
54
+ const editor = buildListEditor()
55
+ editor.update(
56
+ () => {
57
+ const root = $getRoot()
58
+ root.clear()
59
+ const ul = $createListNode("bullet")
60
+ const a = $createListItemNode()
61
+ a.append($createTextNode("a"))
62
+ const b = $createListItemNode()
63
+ const textB = $createTextNode("b")
64
+ b.append(textB)
65
+ ul.append(a, b)
66
+ root.append(ul)
67
+ // Select the text node (a real cursor), not the list item element, so
68
+ // the indent command resolves the block to the ListItemNode.
69
+ textB.selectEnd()
70
+ },
71
+ { discrete: true }
72
+ )
73
+
74
+ // Before: one top-level list, no nested lists.
75
+ editor.read(() => {
76
+ expect(countNestedLists($getRoot())).toBe(1)
77
+ })
78
+
79
+ const handled = editor.dispatchCommand(KEY_TAB_COMMAND, {
80
+ preventDefault: () => {},
81
+ shiftKey: false
82
+ })
83
+ expect(handled).toBe(true)
84
+
85
+ // After: a nested list now exists (the second item became a child list).
86
+ editor.read(() => {
87
+ expect(countNestedLists($getRoot())).toBeGreaterThan(1)
88
+ })
89
+ })
90
+
91
+ it("un-nests a nested item on Shift+Tab", () => {
92
+ const editor = buildListEditor()
93
+ editor.update(
94
+ () => {
95
+ const root = $getRoot()
96
+ root.clear()
97
+ const ul = $createListNode("bullet")
98
+ const a = $createListItemNode()
99
+ a.append($createTextNode("a"))
100
+ const b = $createListItemNode()
101
+ const textB = $createTextNode("b")
102
+ b.append(textB)
103
+ ul.append(a, b)
104
+ root.append(ul)
105
+ textB.selectEnd()
106
+ },
107
+ { discrete: true }
108
+ )
109
+ // Nest first.
110
+ editor.dispatchCommand(KEY_TAB_COMMAND, { preventDefault: () => {}, shiftKey: false })
111
+ editor.read(() => {
112
+ expect(countNestedLists($getRoot())).toBeGreaterThan(1)
113
+ })
114
+
115
+ // Place selection back on the (now nested) "b" and outdent.
116
+ editor.update(
117
+ () => {
118
+ const root = $getRoot()
119
+ const target = root
120
+ .getAllTextNodes()
121
+ .find((n) => n.getTextContent() === "b")
122
+ target.selectEnd()
123
+ },
124
+ { discrete: true }
125
+ )
126
+ const handled = editor.dispatchCommand(KEY_TAB_COMMAND, {
127
+ preventDefault: () => {},
128
+ shiftKey: true
129
+ })
130
+ expect(handled).toBe(true)
131
+ editor.read(() => {
132
+ expect(countNestedLists($getRoot())).toBe(1)
133
+ })
134
+ })
135
+
136
+ it("Shift+Tab on a top-level item removes it from the list (becomes a paragraph)", () => {
137
+ const editor = buildListEditor()
138
+ editor.update(
139
+ () => {
140
+ const root = $getRoot()
141
+ root.clear()
142
+ const ul = $createListNode("bullet")
143
+ const a = $createListItemNode()
144
+ const textA = $createTextNode("a")
145
+ a.append(textA)
146
+ ul.append(a)
147
+ root.append(ul)
148
+ textA.selectEnd()
149
+ },
150
+ { discrete: true }
151
+ )
152
+
153
+ const handled = editor.dispatchCommand(KEY_TAB_COMMAND, {
154
+ preventDefault: () => {},
155
+ shiftKey: true
156
+ })
157
+ expect(handled).toBe(true)
158
+
159
+ editor.read(() => {
160
+ const root = $getRoot()
161
+ // The whole list is gone; the item is now a top-level paragraph.
162
+ expect(root.getChildren().some($isListNode)).toBe(false)
163
+ const only = root.getFirstChild()
164
+ expect($isParagraphNode(only)).toBe(true)
165
+ expect(only.getTextContent()).toBe("a")
166
+ })
167
+ })
168
+
169
+ it("Shift+Tab on a middle top-level item splits the surrounding list", () => {
170
+ const editor = buildListEditor()
171
+ editor.update(
172
+ () => {
173
+ const root = $getRoot()
174
+ root.clear()
175
+ const ul = $createListNode("bullet")
176
+ const a = $createListItemNode()
177
+ a.append($createTextNode("a"))
178
+ const b = $createListItemNode()
179
+ const textB = $createTextNode("b")
180
+ b.append(textB)
181
+ const c = $createListItemNode()
182
+ c.append($createTextNode("c"))
183
+ ul.append(a, b, c)
184
+ root.append(ul)
185
+ textB.selectEnd()
186
+ },
187
+ { discrete: true }
188
+ )
189
+
190
+ const handled = editor.dispatchCommand(KEY_TAB_COMMAND, {
191
+ preventDefault: () => {},
192
+ shiftKey: true
193
+ })
194
+ expect(handled).toBe(true)
195
+
196
+ editor.read(() => {
197
+ const root = $getRoot()
198
+ const kinds = root.getChildren().map((child) =>
199
+ $isListNode(child) ? `list[${child.getTextContent()}]` : `p[${child.getTextContent()}]`
200
+ )
201
+ // a stays in the first list, b becomes a paragraph, c moves to a trailing list.
202
+ expect(kinds).toEqual(["list[a]", "p[b]", "list[c]"])
203
+ })
204
+ })
205
+
206
+ it("Shift+Tab un-lists every top-level item the selection spans, not just the anchor", () => {
207
+ const editor = buildListEditor()
208
+ editor.update(
209
+ () => {
210
+ const root = $getRoot()
211
+ root.clear()
212
+ const ul = $createListNode("bullet")
213
+ const a = $createListItemNode()
214
+ a.append($createTextNode("a"))
215
+ const b = $createListItemNode()
216
+ b.append($createTextNode("b"))
217
+ const c = $createListItemNode()
218
+ c.append($createTextNode("c"))
219
+ ul.append(a, b, c)
220
+ root.append(ul)
221
+ // Select from inside "a" through inside "c" (spans all three items).
222
+ const selection = $createRangeSelection()
223
+ selection.anchor.set(a.getFirstChild().getKey(), 0, "text")
224
+ selection.focus.set(c.getFirstChild().getKey(), 1, "text")
225
+ $setSelection(selection)
226
+ },
227
+ { discrete: true }
228
+ )
229
+
230
+ const handled = editor.dispatchCommand(KEY_TAB_COMMAND, {
231
+ preventDefault: () => {},
232
+ shiftKey: true
233
+ })
234
+ expect(handled).toBe(true)
235
+
236
+ editor.read(() => {
237
+ const root = $getRoot()
238
+ // All three leave the list: no list node survives, three plain paragraphs in order.
239
+ expect(root.getChildren().some($isListNode)).toBe(false)
240
+ const kinds = root.getChildren().map((child) => `p[${child.getTextContent()}]`)
241
+ expect(kinds).toEqual(["p[a]", "p[b]", "p[c]"])
242
+ })
243
+ })
244
+
245
+ it("Shift+Tab on a top-level item with a nested child list never puts a list inside a paragraph", () => {
246
+ const editor = buildListEditor()
247
+ editor.update(
248
+ () => {
249
+ const root = $getRoot()
250
+ root.clear()
251
+ const ul = $createListNode("bullet")
252
+ const a = $createListItemNode()
253
+ a.append($createTextNode("a"))
254
+ const child = $createListItemNode()
255
+ const childText = $createTextNode("a1")
256
+ child.append(childText)
257
+ ul.append(a, child)
258
+ root.append(ul)
259
+ childText.selectEnd()
260
+ },
261
+ { discrete: true }
262
+ )
263
+
264
+ // Tab on "a1" nests it under "a" via the real @lexical/list machinery, so the
265
+ // tree matches what a user actually builds (not a hand-rolled approximation).
266
+ editor.dispatchCommand(KEY_TAB_COMMAND, { preventDefault: () => {}, shiftKey: false })
267
+
268
+ editor.update(
269
+ () => {
270
+ const root = $getRoot()
271
+ const topList = root.getFirstChild()
272
+ topList.getFirstChild().getFirstChild().selectEnd() // caret into "a"
273
+ },
274
+ { discrete: true }
275
+ )
276
+
277
+ const handled = editor.dispatchCommand(KEY_TAB_COMMAND, {
278
+ preventDefault: () => {},
279
+ shiftKey: true
280
+ })
281
+ expect(handled).toBe(true)
282
+
283
+ editor.read(() => {
284
+ const root = $getRoot()
285
+ // "a" leaves the list as a paragraph and that paragraph must hold no list block.
286
+ const paragraph = root.getChildren().find($isParagraphNode)
287
+ expect(paragraph).toBeDefined()
288
+ expect(paragraph.getTextContent()).toBe("a")
289
+ expect(paragraph.getChildren().some($isListNode)).toBe(false)
290
+ // "a1" was a child of the removed "a", so it must be PROMOTED to a top-level
291
+ // item of the trailing list — not left as an orphan <li><ul> wrapper.
292
+ const trailingList = root.getChildren().filter($isListNode).pop()
293
+ const directItems = trailingList
294
+ .getChildren()
295
+ .filter((li) => $isListItemNode(li) && !$isListNode(li.getFirstChild()))
296
+ .map((li) => li.getTextContent())
297
+ expect(directItems).toContain("a1")
298
+ // No orphan nested-list wrapper survives at the trailing list's top level.
299
+ const hasOrphanWrapper = trailingList
300
+ .getChildren()
301
+ .some((li) => $isListItemNode(li) && $isListNode(li.getFirstChild()))
302
+ expect(hasOrphanWrapper).toBe(false)
303
+ // The original list must not linger as an empty/orphan list before the
304
+ // paragraph: only the single trailing list should remain at the root, and no
305
+ // empty list anywhere (it would serialize as stray blank lines in Markdown).
306
+ const rootLists = root.getChildren().filter($isListNode)
307
+ expect(rootLists.length).toBe(1)
308
+ expect(rootLists[0]).toBe(trailingList)
309
+ })
310
+ })
311
+
312
+ it("Shift+Tab promotes only the removed item's children, leaving later items' nesting intact", () => {
313
+ const editor = buildListEditor()
314
+ editor.update(
315
+ () => {
316
+ const root = $getRoot()
317
+ root.clear()
318
+ const ul = $createListNode("bullet")
319
+ const a = $createListItemNode()
320
+ a.append($createTextNode("a"))
321
+ const b = $createListItemNode()
322
+ b.append($createTextNode("b"))
323
+ const b1 = $createListItemNode()
324
+ const textB1 = $createTextNode("b1")
325
+ b1.append(textB1)
326
+ ul.append(a, b, b1)
327
+ root.append(ul)
328
+ textB1.selectEnd()
329
+ },
330
+ { discrete: true }
331
+ )
332
+
333
+ // Nest "b1" under "b" via the real machinery.
334
+ editor.dispatchCommand(KEY_TAB_COMMAND, { preventDefault: () => {}, shiftKey: false })
335
+
336
+ // Caret into top-level "a" and outdent it.
337
+ editor.update(
338
+ () => {
339
+ const root = $getRoot()
340
+ root.getFirstChild().getFirstChild().getFirstChild().selectEnd()
341
+ },
342
+ { discrete: true }
343
+ )
344
+ const handled = editor.dispatchCommand(KEY_TAB_COMMAND, {
345
+ preventDefault: () => {},
346
+ shiftKey: true
347
+ })
348
+ expect(handled).toBe(true)
349
+
350
+ editor.read(() => {
351
+ const root = $getRoot()
352
+ // "a" has no children, so nothing is promoted; "b1" stays nested under "b".
353
+ const trailingList = root.getChildren().filter($isListNode).pop()
354
+ const topTexts = trailingList
355
+ .getChildren()
356
+ .filter((li) => $isListItemNode(li) && !$isListNode(li.getFirstChild()))
357
+ .map((li) => li.getTextContent())
358
+ expect(topTexts).toContain("b")
359
+ expect(topTexts).not.toContain("b1")
360
+ // b1 remains nested (one wrapper holding b1's sublist).
361
+ expect(countNestedLists(root)).toBeGreaterThan(0)
362
+ expect(root.getTextContent()).toContain("b1")
363
+ })
364
+ })
365
+
366
+ it("Shift+Tab promoting a differently-typed child list keeps the child's own marker", () => {
367
+ const editor = buildListEditor()
368
+ editor.update(
369
+ () => {
370
+ const root = $getRoot()
371
+ root.clear()
372
+ // A numbered parent with a bullet sub-list — "1. a / - a1" (valid mixed
373
+ // Markdown list). Built directly because Tab-nesting inherits the parent
374
+ // type, so it can't produce a differently-typed child.
375
+ const ol = $createListNode("number")
376
+ const a = $createListItemNode()
377
+ a.append($createTextNode("a"))
378
+ const wrapper = $createListItemNode()
379
+ const childList = $createListNode("bullet")
380
+ const a1 = $createListItemNode()
381
+ a1.append($createTextNode("a1"))
382
+ childList.append(a1)
383
+ wrapper.append(childList)
384
+ ol.append(a, wrapper)
385
+ root.append(ol)
386
+ a.getFirstChild().selectEnd() // caret into "a"
387
+ },
388
+ { discrete: true }
389
+ )
390
+
391
+ const handled = editor.dispatchCommand(KEY_TAB_COMMAND, {
392
+ preventDefault: () => {},
393
+ shiftKey: true
394
+ })
395
+ expect(handled).toBe(true)
396
+
397
+ editor.read(() => {
398
+ const root = $getRoot()
399
+ const paragraph = root.getChildren().find($isParagraphNode)
400
+ expect(paragraph.getTextContent()).toBe("a")
401
+ // a1 was a's bullet child; promoted up it must stay a bullet list, not be
402
+ // renumbered into an ordered list inheriting the parent's "number" type.
403
+ const lists = root.getChildren().filter($isListNode)
404
+ expect(lists.length).toBe(1)
405
+ expect(lists[0].getListType()).toBe("bullet")
406
+ expect(lists[0].getTextContent()).toContain("a1")
407
+ })
408
+ })
409
+
410
+ it("Shift+Tab on a list nested in a blockquote removes the list formatting", () => {
411
+ const editor = buildListEditor()
412
+ editor.update(
413
+ () => {
414
+ const root = $getRoot()
415
+ root.clear()
416
+ // A list whose parent is a QuoteNode, not the root — `> - item`.
417
+ const quote = new QuoteNode()
418
+ const ul = $createListNode("bullet")
419
+ const a = $createListItemNode()
420
+ const textA = $createTextNode("quoted")
421
+ a.append(textA)
422
+ ul.append(a)
423
+ quote.append(ul)
424
+ root.append(quote)
425
+ textA.selectEnd()
426
+ },
427
+ { discrete: true }
428
+ )
429
+
430
+ const handled = editor.dispatchCommand(KEY_TAB_COMMAND, {
431
+ preventDefault: () => {},
432
+ shiftKey: true
433
+ })
434
+ expect(handled).toBe(true)
435
+
436
+ editor.read(() => {
437
+ const root = $getRoot()
438
+ // The list must be gone (no `<ul>` anywhere), the text survives, and no
439
+ // list block remains — `> item` instead of an unchanged `> - item`.
440
+ expect(countNestedLists(root)).toBe(0)
441
+ expect(root.getTextContent()).toContain("quoted")
442
+ })
443
+ })
444
+
445
+ it("Shift+Tab splitting an ordered list keeps the trailing numbering", () => {
446
+ const editor = buildListEditor()
447
+ editor.update(
448
+ () => {
449
+ const root = $getRoot()
450
+ root.clear()
451
+ const ol = $createListNode("number")
452
+ const a = $createListItemNode()
453
+ a.append($createTextNode("a"))
454
+ const b = $createListItemNode()
455
+ const textB = $createTextNode("b")
456
+ b.append(textB)
457
+ const c = $createListItemNode()
458
+ c.append($createTextNode("c"))
459
+ ol.append(a, b, c)
460
+ root.append(ol)
461
+ textB.selectEnd()
462
+ },
463
+ { discrete: true }
464
+ )
465
+
466
+ const handled = editor.dispatchCommand(KEY_TAB_COMMAND, {
467
+ preventDefault: () => {},
468
+ shiftKey: true
469
+ })
470
+ expect(handled).toBe(true)
471
+
472
+ editor.read(() => {
473
+ const root = $getRoot()
474
+ const lists = root.getChildren().filter($isListNode)
475
+ // a stays in the first ordered list; c moves to a trailing ordered list that
476
+ // continues numbering from 3 (b was 2), not restarting at 1.
477
+ const trailing = lists[lists.length - 1]
478
+ expect(trailing.getListType()).toBe("number")
479
+ expect(trailing.getStart()).toBe(3)
480
+ expect(trailing.getTextContent()).toBe("c")
481
+ })
482
+ })
483
+
484
+ it("Shift+Tab keeps the trailing numbering when an ordered child list is promoted", () => {
485
+ const editor = buildListEditor()
486
+ editor.update(
487
+ () => {
488
+ const root = $getRoot()
489
+ root.clear()
490
+ // "1. a / 2. b / 3. c" with an ordered child "b1" nested under "b".
491
+ const ol = $createListNode("number")
492
+ const a = $createListItemNode()
493
+ a.append($createTextNode("a"))
494
+ const b = $createListItemNode()
495
+ b.append($createTextNode("b"))
496
+ const b1 = $createListItemNode()
497
+ const textB1 = $createTextNode("b1")
498
+ b1.append(textB1)
499
+ const c = $createListItemNode()
500
+ c.append($createTextNode("c"))
501
+ ol.append(a, b, b1, c)
502
+ root.append(ol)
503
+ textB1.selectEnd()
504
+ },
505
+ { discrete: true }
506
+ )
507
+ // Nest "b1" under "b" via the real machinery (same ordered type as the parent).
508
+ editor.dispatchCommand(KEY_TAB_COMMAND, { preventDefault: () => {}, shiftKey: false })
509
+ // Caret into top-level "b" and outdent it.
510
+ editor.update(
511
+ () => {
512
+ const root = $getRoot()
513
+ const target = root.getAllTextNodes().find((n) => n.getTextContent() === "b")
514
+ target.selectEnd()
515
+ },
516
+ { discrete: true }
517
+ )
518
+ const handled = editor.dispatchCommand(KEY_TAB_COMMAND, {
519
+ preventDefault: () => {},
520
+ shiftKey: true
521
+ })
522
+ expect(handled).toBe(true)
523
+
524
+ editor.read(() => {
525
+ const root = $getRoot()
526
+ // The promoted child list (b1) and the continuation (c) are forced into ONE
527
+ // ordered list by Lexical's same-type-sibling merge. The continuation "c" must
528
+ // keep its original value 3, with the promoted child filling the slot before it.
529
+ const lists = root.getChildren().filter($isListNode)
530
+ const trailing = lists[lists.length - 1]
531
+ expect(trailing.getListType()).toBe("number")
532
+ const values = trailing
533
+ .getChildren()
534
+ .filter((li) => $isListItemNode(li) && !$isListNode(li.getFirstChild()))
535
+ .map((li) => li.getValue())
536
+ // start=2 → b1=2, c=3 (c is not renumbered down to 2 by the merge).
537
+ expect(values).toEqual([2, 3])
538
+ expect(trailing.getTextContent()).toContain("b1")
539
+ expect(trailing.getTextContent()).toContain("c")
540
+ })
541
+ })
542
+
543
+ it("Shift+Tab never produces a zero/negative start with multiple promoted children", () => {
544
+ const editor = buildListEditor()
545
+ editor.update(
546
+ () => {
547
+ const root = $getRoot()
548
+ root.clear()
549
+ // "1. b" (first item) with two ordered children "b1", "b2" and a sibling "c".
550
+ const ol = $createListNode("number")
551
+ const b = $createListItemNode()
552
+ b.append($createTextNode("b"))
553
+ const b1 = $createListItemNode()
554
+ b1.append($createTextNode("b1"))
555
+ const b2 = $createListItemNode()
556
+ b2.append($createTextNode("b2"))
557
+ const c = $createListItemNode()
558
+ c.append($createTextNode("c"))
559
+ ol.append(b, b1, b2, c)
560
+ root.append(ol)
561
+ },
562
+ { discrete: true }
563
+ )
564
+ // Nest both "b1" and "b2" under "b" via the real machinery.
565
+ for (const text of ["b1", "b2"]) {
566
+ editor.update(
567
+ () => {
568
+ $getRoot()
569
+ .getAllTextNodes()
570
+ .find((n) => n.getTextContent() === text)
571
+ .selectEnd()
572
+ },
573
+ { discrete: true }
574
+ )
575
+ editor.dispatchCommand(KEY_TAB_COMMAND, { preventDefault: () => {}, shiftKey: false })
576
+ }
577
+ // Caret into top-level "b" and outdent it.
578
+ editor.update(
579
+ () => {
580
+ $getRoot()
581
+ .getAllTextNodes()
582
+ .find((n) => n.getTextContent() === "b")
583
+ .selectEnd()
584
+ },
585
+ { discrete: true }
586
+ )
587
+ const handled = editor.dispatchCommand(KEY_TAB_COMMAND, {
588
+ preventDefault: () => {},
589
+ shiftKey: true
590
+ })
591
+ expect(handled).toBe(true)
592
+
593
+ editor.read(() => {
594
+ const root = $getRoot()
595
+ const lists = root.getChildren().filter($isListNode)
596
+ const trailing = lists[lists.length - 1]
597
+ // The naive start (b.value + 1 - 2 promoted children = 0) would render "0.".
598
+ // Clamped to 1 → b1=1, b2=2, c=3 (sequential, all positive).
599
+ expect(trailing.getStart()).toBeGreaterThanOrEqual(1)
600
+ const values = trailing
601
+ .getChildren()
602
+ .filter((li) => $isListItemNode(li) && !$isListNode(li.getFirstChild()))
603
+ .map((li) => li.getValue())
604
+ expect(values).toEqual([1, 2, 3])
605
+ expect(values.every((v) => v >= 1)).toBe(true)
606
+ })
607
+ })
608
+
609
+ it("ignores Tab outside a list (returns false, no preventDefault)", () => {
610
+ const editor = buildListEditor()
611
+ editor.update(
612
+ () => {
613
+ const root = $getRoot()
614
+ root.clear()
615
+ const p = $createParagraphNode()
616
+ p.append($createTextNode("plain"))
617
+ root.append(p)
618
+ p.selectEnd()
619
+ },
620
+ { discrete: true }
621
+ )
622
+
623
+ let prevented = false
624
+ const handled = editor.dispatchCommand(KEY_TAB_COMMAND, {
625
+ preventDefault: () => {
626
+ prevented = true
627
+ },
628
+ shiftKey: false
629
+ })
630
+ expect(handled).toBe(false)
631
+ expect(prevented).toBe(false)
632
+ })
633
+ })