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,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
|
+
})
|