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,88 @@
1
+ import {
2
+ createEditor,
3
+ $getRoot,
4
+ $createParagraphNode,
5
+ $createTextNode,
6
+ $getSelection
7
+ } from "lexical"
8
+ import { registerRichText } from "@lexical/rich-text"
9
+ import {
10
+ isSelectionAtDocumentStart,
11
+ isSelectionAtDocumentEnd
12
+ } from "../selection_boundary"
13
+
14
+ function buildEditor() {
15
+ const editor = createEditor({
16
+ namespace: "test",
17
+ onError(error) {
18
+ throw error
19
+ }
20
+ })
21
+ registerRichText(editor)
22
+ return editor
23
+ }
24
+
25
+ // Builds a document with the given lines (one paragraph per line) and places a
26
+ // collapsed cursor in the requested paragraph at the requested offset.
27
+ function setup(lines, { paragraphIndex, offset }) {
28
+ const editor = buildEditor()
29
+ let result = null
30
+ editor.update(
31
+ () => {
32
+ const root = $getRoot()
33
+ root.clear()
34
+ const textNodes = lines.map((line) => {
35
+ const p = $createParagraphNode()
36
+ const t = $createTextNode(line)
37
+ p.append(t)
38
+ root.append(p)
39
+ return t
40
+ })
41
+ const target = textNodes[paragraphIndex]
42
+ target.select(offset, offset)
43
+ },
44
+ { discrete: true }
45
+ )
46
+ editor.getEditorState().read(() => {
47
+ const selection = $getSelection()
48
+ result = {
49
+ atStart: isSelectionAtDocumentStart(selection),
50
+ atEnd: isSelectionAtDocumentEnd(selection)
51
+ }
52
+ })
53
+ return result
54
+ }
55
+
56
+ describe("selection boundary helpers", () => {
57
+ it("treats cursor at the very start of the first line as document start", () => {
58
+ const r = setup(["line1", "line2"], { paragraphIndex: 0, offset: 0 })
59
+ expect(r.atStart).toBe(true)
60
+ })
61
+
62
+ it("does NOT treat start of the second paragraph as document start", () => {
63
+ // This is the bug: after Enter, the cursor sits at offset 0 of a new
64
+ // paragraph. ArrowUp must move the cursor up, not jump to the row above.
65
+ const r = setup(["line1", "line2"], { paragraphIndex: 1, offset: 0 })
66
+ expect(r.atStart).toBe(false)
67
+ })
68
+
69
+ it("does NOT treat mid-text cursor as document start", () => {
70
+ const r = setup(["line1"], { paragraphIndex: 0, offset: 2 })
71
+ expect(r.atStart).toBe(false)
72
+ })
73
+
74
+ it("treats cursor at the very end of the last line as document end", () => {
75
+ const r = setup(["line1", "line2"], { paragraphIndex: 1, offset: 5 })
76
+ expect(r.atEnd).toBe(true)
77
+ })
78
+
79
+ it("does NOT treat end of the first paragraph as document end", () => {
80
+ const r = setup(["line1", "line2"], { paragraphIndex: 0, offset: 5 })
81
+ expect(r.atEnd).toBe(false)
82
+ })
83
+
84
+ it("does NOT treat mid-text cursor as document end", () => {
85
+ const r = setup(["line1"], { paragraphIndex: 0, offset: 2 })
86
+ expect(r.atEnd).toBe(false)
87
+ })
88
+ })
@@ -0,0 +1,163 @@
1
+ import { createEditor, $getRoot, $createParagraphNode, $createTextNode } from "lexical"
2
+ import { $convertFromMarkdownString, $convertToMarkdownString } from "@lexical/markdown"
3
+ import {
4
+ TableNode,
5
+ TableRowNode,
6
+ TableCellNode,
7
+ $createTableNode,
8
+ $createTableRowNode,
9
+ $createTableCellNode,
10
+ TableCellHeaderStates
11
+ } from "@lexical/table"
12
+ import { HeadingNode, QuoteNode } from "@lexical/rich-text"
13
+ import { ListNode, ListItemNode } from "@lexical/list"
14
+ import { LinkNode } from "@lexical/link"
15
+ import { CodeNode, CodeHighlightNode } from "@lexical/code"
16
+ import { MARKDOWN_TRANSFORMERS } from "../markdown_serialize"
17
+
18
+ // Drives a headless editor through markdown -> Lexical -> markdown so the TABLE
19
+ // transformer's import (replace) and export paths are exercised together.
20
+ function makeEditor() {
21
+ return createEditor({
22
+ namespace: "table-test",
23
+ onError(error) {
24
+ throw error
25
+ },
26
+ nodes: [
27
+ HeadingNode,
28
+ QuoteNode,
29
+ ListNode,
30
+ ListItemNode,
31
+ LinkNode,
32
+ CodeNode,
33
+ CodeHighlightNode,
34
+ TableNode,
35
+ TableRowNode,
36
+ TableCellNode
37
+ ]
38
+ })
39
+ }
40
+
41
+ function roundTrip(markdown) {
42
+ const editor = makeEditor()
43
+ editor.update(
44
+ () => {
45
+ $convertFromMarkdownString(markdown, MARKDOWN_TRANSFORMERS)
46
+ },
47
+ { discrete: true }
48
+ )
49
+ let result = ""
50
+ editor.getEditorState().read(() => {
51
+ result = $convertToMarkdownString(MARKDOWN_TRANSFORMERS)
52
+ })
53
+ return result
54
+ }
55
+
56
+ // Build a 1x1 table whose only cell holds the given literal text, export it to
57
+ // markdown, then re-import and read the cell back — i.e. a true save -> reopen
58
+ // cycle that never assumes how the text is escaped on the wire.
59
+ function saveReopenCellText(cellText) {
60
+ const writer = makeEditor()
61
+ let markdown = ""
62
+ writer.update(
63
+ () => {
64
+ const cell = $createTableCellNode(TableCellHeaderStates.NO_STATUS)
65
+ cell.append($createParagraphNode().append($createTextNode(cellText)))
66
+ const table = $createTableNode()
67
+ table.append($createTableRowNode().append(cell))
68
+ $getRoot().append(table)
69
+ },
70
+ { discrete: true }
71
+ )
72
+ writer.getEditorState().read(() => {
73
+ markdown = $convertToMarkdownString(MARKDOWN_TRANSFORMERS)
74
+ })
75
+
76
+ const reader = makeEditor()
77
+ reader.update(
78
+ () => {
79
+ $convertFromMarkdownString(markdown, MARKDOWN_TRANSFORMERS)
80
+ },
81
+ { discrete: true }
82
+ )
83
+ let text = null
84
+ reader.getEditorState().read(() => {
85
+ const table = $getRoot()
86
+ .getChildren()
87
+ .find((n) => n.getType() === "table")
88
+ text = table ? table.getFirstChild().getFirstChild().getTextContent() : null
89
+ })
90
+ return text
91
+ }
92
+
93
+ describe("TABLE markdown transformer", () => {
94
+ it("round-trips a header + body table", () => {
95
+ const md = "| Name | Age |\n| --- | --- |\n| Ada | 36 |\n| Linus | 54 |"
96
+ const out = roundTrip(md)
97
+ expect(out).toContain("| Name | Age |")
98
+ expect(out).toContain("| --- | --- |")
99
+ expect(out).toContain("| Ada | 36 |")
100
+ expect(out).toContain("| Linus | 54 |")
101
+ })
102
+
103
+ it("round-trips a table with an empty cell", () => {
104
+ const md = "| A | B |\n| --- | --- |\n| | y |"
105
+ const out = roundTrip(md)
106
+ expect(out).toContain("| A | B |")
107
+ expect(out).toContain("| | y |")
108
+ })
109
+
110
+ it("preserves inline formatting inside cells", () => {
111
+ const md = "| H |\n| --- |\n| **bold** |"
112
+ expect(roundTrip(md)).toContain("**bold**")
113
+ })
114
+
115
+ it("leaves a non-table paragraph untouched", () => {
116
+ const md = "Just a sentence."
117
+ expect(roundTrip(md).trim()).toBe("Just a sentence.")
118
+ })
119
+
120
+ it("treats an alignment divider (leading/trailing colons) as a header", () => {
121
+ const md = "| L | R |\n| :--- | ---: |\n| a | b |"
122
+ const out = roundTrip(md)
123
+ // A divider row promotes the prior row to a header, which re-exports as "---".
124
+ expect(out).toContain("| L | R |")
125
+ expect(out).toContain("| --- | --- |")
126
+ expect(out).toContain("| a | b |")
127
+ })
128
+
129
+ it("preserves a literal pipe typed inside a cell", () => {
130
+ // A user types "a|b" into a cell. Without escaping, export emits an
131
+ // unescaped pipe which GFM re-parses as an extra column on the next render.
132
+ const md = "| H1 | H2 |\n| --- | --- |\n| a\\|b | ok |"
133
+ const out = roundTrip(md)
134
+ expect(out).toContain("| a\\|b | ok |")
135
+ })
136
+
137
+ it("save->reopen preserves a literal backslash-n (not a newline) in a cell", () => {
138
+ // "a\nb" is the literal chars a, backslash, n, b (e.g. a regex or path), NOT
139
+ // a newline. A prior bug decoded the backslash-n into a real newline, splitting
140
+ // the cell (CodeQL #42). The escape must be complete (CodeQL #43, backslash).
141
+ expect(saveReopenCellText("a\\nb")).toBe("a\\nb")
142
+ })
143
+
144
+ it("save->reopen preserves a literal backslash in a cell", () => {
145
+ expect(saveReopenCellText("C:\\path")).toBe("C:\\path")
146
+ })
147
+
148
+ it("save->reopen preserves a backslash directly before a pipe in a cell", () => {
149
+ // The adversarial case for incomplete escaping: a backslash adjacent to the
150
+ // pipe that gets escaped. Both must survive intact.
151
+ expect(saveReopenCellText("a\\|b")).toBe("a\\|b")
152
+ })
153
+
154
+ it("does not hang on a pathological colon/pipe row (ReDoS guard)", () => {
155
+ // Before the -+ fix, the divider regex backtracked exponentially on rows of
156
+ // alternating colons and pipes. This must complete effectively instantly.
157
+ const evil = "|" + Array(40).fill(":").join("|") + "|x"
158
+ const start = process.hrtime.bigint()
159
+ roundTrip(evil)
160
+ const ms = Number(process.hrtime.bigint() - start) / 1e6
161
+ expect(ms).toBeLessThan(1000)
162
+ })
163
+ })
@@ -0,0 +1,104 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import {
5
+ createEditor,
6
+ $getRoot,
7
+ $createParagraphNode,
8
+ $createTextNode,
9
+ $applyNodeReplacement,
10
+ DecoratorNode,
11
+ } from "lexical"
12
+ import { ensureTrailingParagraph, registerTrailingParagraph } from "../trailing_paragraph"
13
+
14
+ // Stand-in for the production image/video/attachment nodes (those are .jsx and
15
+ // can't be imported here). Only the graph-relevant shape matters: a "image"
16
+ // DecoratorNode that is NOT inline, exactly like image_node.jsx.
17
+ class TestImageNode extends DecoratorNode {
18
+ __src
19
+ static getType() { return "image" }
20
+ static clone(n) { return new TestImageNode(n.__src, n.__key) }
21
+ static importJSON() { return $createImage("") }
22
+ constructor(src = "", key) { super(key); this.__src = src }
23
+ createDOM() { return document.createElement("span") }
24
+ updateDOM() { return false }
25
+ decorate() { return null }
26
+ }
27
+ function $createImage(src) { return $applyNodeReplacement(new TestImageNode(src)) }
28
+
29
+ function newEditor() {
30
+ return createEditor({ namespace: "t", nodes: [TestImageNode], onError: (e) => { throw e } })
31
+ }
32
+
33
+ function rootTypes(editor) {
34
+ let types = []
35
+ editor.getEditorState().read(() => {
36
+ types = $getRoot().getChildren().map((c) => c.getType())
37
+ })
38
+ return types
39
+ }
40
+
41
+ describe("ensureTrailingParagraph (pure helper)", () => {
42
+ test("appends a paragraph when the last root child is a decorator", () => {
43
+ const editor = newEditor()
44
+ editor.update(() => {
45
+ const root = $getRoot()
46
+ root.clear()
47
+ const p = $createParagraphNode()
48
+ p.append($createTextNode("hello"))
49
+ root.append(p, $createImage("https://lipcoding.kr/x.png"))
50
+ ensureTrailingParagraph(root)
51
+ }, { discrete: true })
52
+ expect(rootTypes(editor)).toEqual(["paragraph", "image", "paragraph"])
53
+ })
54
+
55
+ test("image-only content becomes editable (image + trailing paragraph)", () => {
56
+ const editor = newEditor()
57
+ editor.update(() => {
58
+ const root = $getRoot()
59
+ root.clear()
60
+ root.append($createImage("https://lipcoding.kr/x.png"))
61
+ ensureTrailingParagraph(root)
62
+ }, { discrete: true })
63
+ expect(rootTypes(editor)).toEqual(["image", "paragraph"])
64
+ })
65
+
66
+ test("no-op when the last child is already a paragraph", () => {
67
+ const editor = newEditor()
68
+ editor.update(() => {
69
+ const root = $getRoot()
70
+ root.clear()
71
+ root.append($createImage("a"), $createParagraphNode())
72
+ ensureTrailingParagraph(root)
73
+ }, { discrete: true })
74
+ expect(rootTypes(editor)).toEqual(["image", "paragraph"])
75
+ })
76
+ })
77
+
78
+ describe("registerTrailingParagraph (RootNode transform)", () => {
79
+ test("fires on update: trailing image gets a paragraph appended automatically", () => {
80
+ const editor = newEditor()
81
+ registerTrailingParagraph(editor)
82
+ // Simulate editing that leaves an image as the last block.
83
+ editor.update(() => {
84
+ const root = $getRoot()
85
+ root.clear()
86
+ const p = $createParagraphNode()
87
+ p.append($createTextNode("hello"))
88
+ root.append(p, $createImage("https://lipcoding.kr/x.png"))
89
+ }, { discrete: true })
90
+ expect(rootTypes(editor)).toEqual(["paragraph", "image", "paragraph"])
91
+ })
92
+
93
+ test("terminates (does not append unboundedly)", () => {
94
+ const editor = newEditor()
95
+ registerTrailingParagraph(editor)
96
+ editor.update(() => {
97
+ const root = $getRoot()
98
+ root.clear()
99
+ root.append($createImage("a"))
100
+ }, { discrete: true })
101
+ // exactly one trailing paragraph, not many
102
+ expect(rootTypes(editor)).toEqual(["image", "paragraph"])
103
+ })
104
+ })
@@ -0,0 +1,210 @@
1
+ import {
2
+ $createParagraphNode,
3
+ $getSelection,
4
+ $isRangeSelection,
5
+ COMMAND_PRIORITY_LOW,
6
+ INDENT_CONTENT_COMMAND,
7
+ KEY_TAB_COMMAND,
8
+ OUTDENT_CONTENT_COMMAND
9
+ } from "lexical"
10
+ import { $createListNode, $isListItemNode, $isListNode } from "@lexical/list"
11
+
12
+ /**
13
+ * Tab / Shift+Tab indentation scoped to list items.
14
+ *
15
+ * Inside a list, Tab nests the current item (INDENT_CONTENT_COMMAND).
16
+ * Shift+Tab un-nests it (OUTDENT_CONTENT_COMMAND) when the item is nested;
17
+ * @lexical/list turns those indent changes into real nested <ul>/<ol> structure.
18
+ *
19
+ * At the OUTERMOST level OUTDENT_CONTENT_COMMAND is a no-op (there is no shallower
20
+ * indent to drop to), so Shift+Tab on a top-level item would do nothing. Other
21
+ * editors instead remove the list formatting for that item — the item leaves the
22
+ * list and becomes a normal paragraph. We replicate that here so Shift+Tab always
23
+ * has a visible, expected effect.
24
+ *
25
+ * Outside a list the command is ignored (returns false) so Tab keeps its default
26
+ * behaviour (moving focus out of the editor) and plain paragraphs never gain an
27
+ * indent the Markdown-canonical store can't represent cleanly.
28
+ *
29
+ * Returns the editor.registerCommand teardown so callers can clean up.
30
+ */
31
+ export function registerListTabIndentation(editor) {
32
+ return editor.registerCommand(
33
+ KEY_TAB_COMMAND,
34
+ (event) => {
35
+ const selection = $getSelection()
36
+ if (!$isRangeSelection(selection)) return false
37
+ const listItem = $findListItemAncestor(selection)
38
+ if (!listItem) return false
39
+
40
+ event.preventDefault()
41
+
42
+ if (event.shiftKey) {
43
+ // Top-level item: drop out of the list entirely (other editors' behaviour),
44
+ // because OUTDENT has nothing shallower to move to. Nested item: un-nest one
45
+ // level via OUTDENT, which @lexical/list collapses back into flat structure.
46
+ if ($isTopLevelListItem(listItem)) {
47
+ // A selection can span several top-level items; un-list each so multi-item
48
+ // Shift+Tab matches the range-aware OUTDENT path instead of only dropping
49
+ // the anchor's item. Collect the items up front (converting one mutates the
50
+ // tree) and convert in document order — each conversion splits the list and
51
+ // leaves the next selected item at the head of the trailing list, so the
52
+ // result is clean sequential paragraphs.
53
+ $selectedTopLevelListItems(selection, listItem).forEach(
54
+ $convertListItemToParagraph
55
+ )
56
+ return true
57
+ }
58
+ return editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined)
59
+ }
60
+
61
+ return editor.dispatchCommand(INDENT_CONTENT_COMMAND, undefined)
62
+ },
63
+ COMMAND_PRIORITY_LOW
64
+ )
65
+ }
66
+
67
+ // A list item that exists only to hold a nested list (its first/only child is a
68
+ // ListNode). @lexical/list uses this wrapper to represent indentation.
69
+ function $isNestedListItemWrapper(node) {
70
+ return $isListItemNode(node) && $isListNode(node.getFirstChild())
71
+ }
72
+
73
+ function $findListItemAncestor(selection) {
74
+ const anchorNode = selection.anchor.getNode()
75
+ if ($isListItemNode(anchorNode)) return anchorNode
76
+ return anchorNode.getParents().find($isListItemNode) || null
77
+ }
78
+
79
+ // The distinct top-level list items the selection touches, in document order.
80
+ // getNodes() returns the range's nodes in document order; we map each to its
81
+ // owning list item and keep only top-level, non-wrapper ones (nested items go
82
+ // through OUTDENT instead). Falls back to the anchor item when the range yields
83
+ // nothing useful (e.g. a collapsed selection on an empty item).
84
+ function $selectedTopLevelListItems(selection, fallbackItem) {
85
+ const seen = new Set()
86
+ const items = []
87
+ selection.getNodes().forEach((node) => {
88
+ const listItem = $isListItemNode(node)
89
+ ? node
90
+ : node.getParents().find($isListItemNode)
91
+ if (!listItem || seen.has(listItem.getKey())) return
92
+ if (!$isTopLevelListItem(listItem) || $isNestedListItemWrapper(listItem)) return
93
+ seen.add(listItem.getKey())
94
+ items.push(listItem)
95
+ })
96
+ if (items.length === 0 && fallbackItem) items.push(fallbackItem)
97
+ return items
98
+ }
99
+
100
+ // A list item is "top level" when its parent list is not nested inside another
101
+ // list item. This holds both at the document root and inside a blockquote (where
102
+ // the list's parent is the QuoteNode, not the root) — in both cases OUTDENT has
103
+ // nothing shallower to drop to, so the item should leave the list instead.
104
+ function $isTopLevelListItem(listItem) {
105
+ const parentList = listItem.getParent()
106
+ if (!$isListNode(parentList)) return false
107
+ return !$isListItemNode(parentList.getParent())
108
+ }
109
+
110
+ // Remove a single top-level list item from its list, turning it into a paragraph
111
+ // in the same position. Items before it stay in the original list; items after it
112
+ // move into a fresh trailing list so the surrounding list is split, not flattened.
113
+ function $convertListItemToParagraph(listItem) {
114
+ const parentList = listItem.getParent()
115
+ if (!$isListNode(parentList)) return
116
+
117
+ const following = listItem.getNextSiblings()
118
+ // An item can hold both inline content (text) and a nested child list. Only the
119
+ // inline content belongs in the paragraph — a nested ListNode inside a paragraph
120
+ // is invalid block structure, so it is promoted to a sibling list block instead.
121
+ const children = listItem.getChildren()
122
+ const nestedLists = children.filter($isListNode)
123
+ const inlineChildren = children.filter((child) => !$isListNode(child))
124
+ const paragraph = $createParagraphNode()
125
+
126
+ parentList.insertAfter(paragraph)
127
+
128
+ // Keep block order: paragraph, then any promoted nested lists, then the split-off
129
+ // trailing items, by chaining insertAfter off the previously inserted block.
130
+ let anchor = paragraph
131
+ nestedLists.forEach((nestedList) => {
132
+ anchor.insertAfter(nestedList)
133
+ anchor = nestedList
134
+ })
135
+
136
+ if (following.length > 0) {
137
+ const listType = parentList.getListType()
138
+ // @lexical/list stores a nested child list as a *wrapper* list item (its only
139
+ // child is a ListNode) placed right after the parent item. The leading run of
140
+ // such wrappers are the removed item's own children: promote each wrapper's
141
+ // inner ListNode up one level as its own block, then drop the emptied wrapper.
142
+ // Moving the whole ListNode (not just its items) preserves the child list's own
143
+ // marker type — a bullet child of a numbered parent must stay a bullet list, not
144
+ // get renumbered. Promoting items rather than the wrapper also empties parentList
145
+ // so the cleanup below can remove it, avoiding an orphan <li><ul>. Once a real
146
+ // item appears the run ends; later wrappers belong to it and must stay nested.
147
+ let promotingChildren = true
148
+ const trailingItems = []
149
+ following.forEach((sibling) => {
150
+ if (promotingChildren && $isNestedListItemWrapper(sibling)) {
151
+ const childList = sibling.getFirstChild()
152
+ anchor.insertAfter(childList)
153
+ anchor = childList
154
+ sibling.remove()
155
+ } else {
156
+ promotingChildren = false
157
+ trailingItems.push(sibling)
158
+ }
159
+ })
160
+ if (trailingItems.length > 0) {
161
+ // For ordered lists the trailing items must keep their original numbers
162
+ // (e.g. "1. a / 2. b / 3. c" → outdent b → c stays 3, not restart at 1).
163
+ //
164
+ // When a same-type list was promoted directly before these items (the removed
165
+ // item's own ordered child list), Lexical's ListNode transform merges the two
166
+ // adjacent same-type lists and keeps the *first* list's start — a separate
167
+ // trailing list's start would be silently discarded. So append the trailing
168
+ // items into that promoted list instead and set its start so the first trailing
169
+ // item lands on its original value, with the promoted children filling the slots
170
+ // leading up to it ("1. a / 2. b / [1.] b1 / 3. c" → outdent b → "2. b1 / 3. c").
171
+ const mergesWithPromoted =
172
+ $isListNode(anchor) && anchor.getListType() === listType
173
+ if (mergesWithPromoted) {
174
+ if (listType === "number") {
175
+ // Count real items already in the promoted list (nested wrappers don't
176
+ // advance numbering) so the first trailing item keeps its original value.
177
+ const promotedItems = anchor
178
+ .getChildren()
179
+ .filter((child) => !$isNestedListItemWrapper(child)).length
180
+ // Clamp to 1: when more children are promoted than the removed item's
181
+ // value frees up (e.g. "1. b" with children b1, b2 → 1 + 1 - 2 = 0),
182
+ // a 0/negative start would render literally as "0."/"-1.". Falling back
183
+ // to 1 just renumbers the merged list sequentially from the top.
184
+ anchor.setStart(Math.max(1, listItem.getValue() + 1 - promotedItems))
185
+ }
186
+ trailingItems.forEach((sibling) => anchor.append(sibling))
187
+ } else {
188
+ const trailingStart =
189
+ listType === "number" ? listItem.getValue() + 1 : undefined
190
+ const trailingList = $createListNode(listType, trailingStart)
191
+ trailingItems.forEach((sibling) => trailingList.append(sibling))
192
+ anchor.insertAfter(trailingList)
193
+ }
194
+ }
195
+ }
196
+
197
+ if (inlineChildren.length > 0) {
198
+ // Moving the existing nodes (not cloning) keeps any caret pointing into them
199
+ // valid, so the cursor stays where the user was typing.
200
+ inlineChildren.forEach((child) => paragraph.append(child))
201
+ } else {
202
+ paragraph.select()
203
+ }
204
+
205
+ listItem.remove()
206
+
207
+ if (parentList.getChildrenSize() === 0) {
208
+ parentList.remove()
209
+ }
210
+ }