collavre 0.22.0 → 0.24.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 (221) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -0
  3. data/app/assets/images/collavre/landing/interface-en-dark.png +0 -0
  4. data/app/assets/images/collavre/landing/interface-en.png +0 -0
  5. data/app/assets/images/collavre/landing/interface-ko-dark.png +0 -0
  6. data/app/assets/images/collavre/landing/interface-ko.png +0 -0
  7. data/app/assets/stylesheets/collavre/actiontext.css +251 -90
  8. data/app/assets/stylesheets/collavre/code_highlight.css +7 -201
  9. data/app/assets/stylesheets/collavre/comments_popup.css +158 -61
  10. data/app/assets/stylesheets/collavre/creatives.css +11 -2
  11. data/app/assets/stylesheets/collavre/landing.css +92 -0
  12. data/app/assets/stylesheets/collavre/modal_dialog.css +32 -0
  13. data/app/assets/stylesheets/collavre/popup.css +4 -2
  14. data/app/assets/stylesheets/collavre/tables.css +91 -0
  15. data/app/channels/collavre/inbox_badge_channel.rb +30 -0
  16. data/app/controllers/collavre/admin/settings_controller.rb +52 -67
  17. data/app/controllers/collavre/api/v1/agents_controller.rb +17 -188
  18. data/app/controllers/collavre/api/v1/base_controller.rb +9 -27
  19. data/app/controllers/collavre/api/v1/mobile/agent_events_controller.rb +224 -0
  20. data/app/controllers/collavre/api/v1/mobile/base_controller.rb +95 -0
  21. data/app/controllers/collavre/api/v1/mobile/devices_controller.rb +31 -0
  22. data/app/controllers/collavre/api/v1/mobile/voice_commands_controller.rb +25 -0
  23. data/app/controllers/collavre/attachments_controller.rb +13 -3
  24. data/app/controllers/collavre/concerns/slide_viewable.rb +8 -32
  25. data/app/controllers/collavre/creatives_controller.rb +47 -103
  26. data/app/controllers/collavre/inbox_items_controller.rb +10 -4
  27. data/app/controllers/collavre/landing_controller.rb +5 -2
  28. data/app/controllers/collavre/tasks_controller.rb +13 -4
  29. data/app/controllers/collavre/topics_controller.rb +54 -1
  30. data/app/controllers/collavre/typo_corrections_controller.rb +39 -0
  31. data/app/controllers/concerns/collavre/api/authenticatable.rb +40 -0
  32. data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +16 -1
  33. data/app/controllers/concerns/collavre/users_controller/registration.rb +41 -1
  34. data/app/errors/collavre/api_error.rb +20 -0
  35. data/app/helpers/collavre/application_helper.rb +1 -0
  36. data/app/helpers/collavre/creatives_helper.rb +30 -8
  37. data/app/javascript/collavre.js +2 -0
  38. data/app/javascript/components/ImageResizer.jsx +9 -3
  39. data/app/javascript/components/InlineLexicalEditor.jsx +157 -72
  40. data/app/javascript/components/__tests__/creative_tree_row_parent_id.test.js +87 -0
  41. data/app/javascript/components/creative_tree_row.js +28 -5
  42. data/app/javascript/components/plugins/list_tab_indent_plugin.jsx +16 -0
  43. data/app/javascript/components/plugins/markdown_shortcuts_plugin.jsx +15 -4
  44. data/app/javascript/components/plugins/table_hover_actions_plugin.jsx +405 -0
  45. data/app/javascript/controllers/__tests__/inbox_badge_controller.test.js +73 -0
  46. data/app/javascript/controllers/__tests__/search_popup_controller.test.js +104 -0
  47. data/app/javascript/controllers/__tests__/topic_list_controller.test.js +82 -0
  48. data/app/javascript/controllers/comment_controller.js +5 -4
  49. data/app/javascript/controllers/comment_version_controller.js +2 -1
  50. data/app/javascript/controllers/comments/__tests__/form_controller_double_submit.test.js +159 -0
  51. data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +3 -2
  52. data/app/javascript/controllers/comments/__tests__/topics_controller_delete.test.js +94 -0
  53. data/app/javascript/controllers/comments/__tests__/topics_controller_topic_list.test.js +61 -0
  54. data/app/javascript/controllers/comments/form_controller.js +21 -5
  55. data/app/javascript/controllers/comments/list_controller.js +18 -17
  56. data/app/javascript/controllers/comments/presence_controller.js +2 -1
  57. data/app/javascript/controllers/comments/topics_controller.js +61 -8
  58. data/app/javascript/controllers/common_popup_controller.js +2 -2
  59. data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +150 -0
  60. data/app/javascript/controllers/creatives/import_controller.js +2 -1
  61. data/app/javascript/controllers/creatives/select_mode_controller.js +2 -1
  62. data/app/javascript/controllers/creatives/tree_controller.js +142 -1
  63. data/app/javascript/controllers/image_lightbox_controller.js +2 -1
  64. data/app/javascript/controllers/inbox_badge_controller.js +33 -0
  65. data/app/javascript/controllers/index.js +7 -1
  66. data/app/javascript/controllers/search_popup_controller.js +17 -1
  67. data/app/javascript/controllers/share_modal_controller.js +4 -3
  68. data/app/javascript/controllers/topic_list_controller.js +78 -0
  69. data/app/javascript/controllers/topic_search_controller.js +2 -1
  70. data/app/javascript/creatives/drag_drop/event_handlers.js +14 -5
  71. data/app/javascript/creatives/topic_move_members_popup.js +156 -0
  72. data/app/javascript/creatives/tree_renderer.js +11 -0
  73. data/app/javascript/lib/__tests__/common_popup_bounds.test.js +58 -0
  74. data/app/javascript/lib/__tests__/turbo_confirm.test.js +81 -0
  75. data/app/javascript/lib/__tests__/typo_correction.test.js +192 -0
  76. data/app/javascript/lib/api/__tests__/api_error.test.js +96 -0
  77. data/app/javascript/lib/api/__tests__/queue_manager.test.js +88 -1
  78. data/app/javascript/lib/api/api_error.js +108 -0
  79. data/app/javascript/lib/api/queue_manager.js +38 -4
  80. data/app/javascript/lib/common_popup.js +42 -10
  81. data/app/javascript/lib/editor/__tests__/code_edit_view_token_parity.test.js +121 -0
  82. data/app/javascript/lib/editor/__tests__/code_language_roundtrip.test.js +152 -0
  83. data/app/javascript/lib/editor/__tests__/code_languages.test.js +93 -0
  84. data/app/javascript/lib/editor/code_languages.js +173 -0
  85. data/app/javascript/lib/editor/code_token_theme.js +41 -0
  86. data/app/javascript/lib/lexical/__tests__/code_block_toggle.test.js +326 -0
  87. data/app/javascript/lib/lexical/__tests__/code_fence_shortcut.test.js +179 -0
  88. data/app/javascript/lib/lexical/__tests__/image_focus.test.js +139 -0
  89. data/app/javascript/lib/lexical/__tests__/list_tab_indent.test.js +633 -0
  90. data/app/javascript/lib/lexical/__tests__/markdown_serialize.test.js +627 -0
  91. data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +20 -1
  92. data/app/javascript/lib/lexical/__tests__/selection_boundary.test.js +88 -0
  93. data/app/javascript/lib/lexical/__tests__/table_transformer.test.js +163 -0
  94. data/app/javascript/lib/lexical/__tests__/trailing_paragraph.test.js +104 -0
  95. data/app/javascript/lib/lexical/code_block_toggle.js +130 -0
  96. data/app/javascript/lib/lexical/code_fence_shortcut.js +60 -0
  97. data/app/javascript/lib/lexical/list_tab_indent.js +210 -0
  98. data/app/javascript/lib/lexical/markdown_serialize.js +320 -0
  99. data/app/javascript/lib/lexical/selection_boundary.js +58 -0
  100. data/app/javascript/lib/lexical/table_transformer.js +182 -0
  101. data/app/javascript/lib/lexical/trailing_paragraph.js +29 -0
  102. data/app/javascript/lib/turbo_confirm.js +46 -0
  103. data/app/javascript/lib/typo_correction.js +146 -0
  104. data/app/javascript/lib/utils/__tests__/confirm_dialog.test.js +88 -0
  105. data/app/javascript/lib/utils/__tests__/dialog.test.js +92 -0
  106. data/app/javascript/lib/utils/__tests__/markdown.test.js +153 -0
  107. data/app/javascript/lib/utils/__tests__/sanitize_description.test.js +68 -0
  108. data/app/javascript/lib/utils/__tests__/table_download.test.js +93 -0
  109. data/app/javascript/lib/utils/confirm_dialog.js +10 -0
  110. data/app/javascript/lib/utils/dialog.js +300 -0
  111. data/app/javascript/lib/utils/markdown.js +154 -67
  112. data/app/javascript/lib/utils/sanitize_description.js +31 -0
  113. data/app/javascript/lib/utils/table_download.js +15 -0
  114. data/app/javascript/modules/__tests__/creative_inline_payload.test.js +231 -0
  115. data/app/javascript/modules/__tests__/creative_save_queue.test.js +228 -0
  116. data/app/javascript/modules/__tests__/creative_tree_dom.test.js +425 -0
  117. data/app/javascript/modules/__tests__/typo_corrector.test.js +365 -0
  118. data/app/javascript/modules/creative_inline_payload.js +86 -0
  119. data/app/javascript/modules/creative_row_editor.js +231 -471
  120. data/app/javascript/modules/creative_row_editor_helpers.js +76 -0
  121. data/app/javascript/modules/creative_save_queue.js +95 -0
  122. data/app/javascript/modules/creative_tree_dom.js +260 -0
  123. data/app/javascript/modules/export_to_markdown.js +2 -1
  124. data/app/javascript/modules/lexical_inline_editor.jsx +2 -1
  125. data/app/javascript/modules/slide_view.js +11 -2
  126. data/app/javascript/modules/typo_corrector.js +534 -0
  127. data/app/jobs/collavre/ai_agent_job.rb +7 -4
  128. data/app/jobs/collavre/compress_job.rb +6 -2
  129. data/app/jobs/collavre/permission_cache_job.rb +20 -2
  130. data/app/jobs/collavre/trigger_loop_check_job.rb +20 -3
  131. data/app/jobs/collavre/update_mcp_tools_job.rb +17 -0
  132. data/app/models/collavre/channel.rb +16 -0
  133. data/app/models/collavre/comment/approvable.rb +16 -0
  134. data/app/models/collavre/comment/broadcastable.rb +46 -7
  135. data/app/models/collavre/comment/notifiable.rb +15 -5
  136. data/app/models/collavre/comment.rb +87 -32
  137. data/app/models/collavre/comment_version.rb +1 -1
  138. data/app/models/collavre/creative/describable.rb +104 -19
  139. data/app/models/collavre/creative/linkable.rb +9 -0
  140. data/app/models/collavre/creative/permissible.rb +97 -49
  141. data/app/models/collavre/creative.rb +73 -5
  142. data/app/models/collavre/creative_share.rb +78 -26
  143. data/app/models/collavre/inbox_item.rb +2 -2
  144. data/app/models/collavre/task.rb +27 -0
  145. data/app/models/collavre/user.rb +90 -10
  146. data/app/models/concerns/collavre/indexed_json_columns.rb +58 -0
  147. data/app/services/collavre/agent_type_classifier.rb +23 -0
  148. data/app/services/collavre/ai_agent/a2a_dispatcher.rb +4 -0
  149. data/app/services/collavre/ai_agent/message_builder.rb +1 -1
  150. data/app/services/collavre/ai_agent/session_provisioner.rb +126 -0
  151. data/app/services/collavre/ai_agent/task_claim_service.rb +96 -0
  152. data/app/services/collavre/ai_client.rb +81 -12
  153. data/app/services/collavre/auto_theme_generator.rb +15 -11
  154. data/app/services/collavre/creatives/children_index.rb +86 -0
  155. data/app/services/collavre/creatives/comment_badge_index.rb +73 -0
  156. data/app/services/collavre/creatives/creative_tree_serializer.rb +122 -0
  157. data/app/services/collavre/creatives/effective_creative_resolution.rb +37 -0
  158. data/app/services/collavre/creatives/index_query.rb +85 -16
  159. data/app/services/collavre/creatives/permission_checker.rb +1 -1
  160. data/app/services/collavre/creatives/permission_filter.rb +144 -17
  161. data/app/services/collavre/creatives/tree_builder.rb +107 -78
  162. data/app/services/collavre/gemini_parent_recommender.rb +1 -1
  163. data/app/services/collavre/http_client.rb +119 -0
  164. data/app/services/collavre/inbox_reply_service.rb +5 -0
  165. data/app/services/collavre/link_preview_fetcher.rb +129 -38
  166. data/app/services/collavre/markdown_converter.rb +13 -3
  167. data/app/services/collavre/markdown_importer.rb +30 -6
  168. data/app/services/collavre/mobile/event_summarizer.rb +40 -0
  169. data/app/services/collavre/orchestration/agent_context_builder.rb +1 -15
  170. data/app/services/collavre/orchestration/agent_orchestrator.rb +34 -8
  171. data/app/services/collavre/orchestration/arbiter.rb +16 -0
  172. data/app/services/collavre/orchestration/matcher.rb +79 -4
  173. data/app/services/collavre/orchestration/policy_resolver.rb +4 -3
  174. data/app/services/collavre/orchestration/stuck_detector.rb +141 -34
  175. data/app/services/collavre/system_events/context_builder.rb +1 -14
  176. data/app/services/collavre/tools/creative_batch_service.rb +3 -2
  177. data/app/services/collavre/tools/creative_create_service.rb +8 -8
  178. data/app/services/collavre/tools/creative_update_service.rb +23 -8
  179. data/app/services/collavre/tools/preview_attach_service.rb +5 -7
  180. data/app/services/collavre/typo_corrector.rb +188 -0
  181. data/app/views/collavre/comments/_comment.html.erb +9 -4
  182. data/app/views/collavre/comments/_comments_popup.html.erb +25 -4
  183. data/app/views/collavre/creatives/_inline_edit_form.html.erb +10 -0
  184. data/app/views/collavre/creatives/_topic_move_members_modal.html.erb +42 -0
  185. data/app/views/collavre/creatives/index.html.erb +14 -1
  186. data/app/views/collavre/creatives/slide_view.html.erb +1 -1
  187. data/app/views/collavre/landing/show.html.erb +52 -10
  188. data/app/views/collavre/users/show.html.erb +3 -0
  189. data/app/views/collavre/users/typo_correction.html.erb +50 -0
  190. data/app/views/layouts/collavre/slide.html.erb +1 -0
  191. data/config/locales/comments.en.yml +16 -0
  192. data/config/locales/comments.ko.yml +16 -0
  193. data/config/locales/creatives.en.yml +6 -0
  194. data/config/locales/creatives.ko.yml +5 -0
  195. data/config/locales/integrations.en.yml +1 -1
  196. data/config/locales/integrations.ko.yml +1 -1
  197. data/config/locales/landing.en.yml +22 -10
  198. data/config/locales/landing.ko.yml +22 -10
  199. data/config/locales/mobile.en.yml +16 -0
  200. data/config/locales/mobile.ko.yml +16 -0
  201. data/config/locales/orchestration.en.yml +1 -0
  202. data/config/locales/orchestration.ko.yml +1 -0
  203. data/config/locales/users.en.yml +15 -0
  204. data/config/locales/users.ko.yml +15 -0
  205. data/config/routes.rb +13 -0
  206. data/db/migrate/20260120045354_encrypt_oauth_tokens.rb +13 -4
  207. data/db/migrate/20260612000000_add_topic_concurrency_defer_to_comments.rb +38 -0
  208. data/db/migrate/20260617090000_add_typo_correction_settings_to_users.rb +18 -0
  209. data/db/migrate/20260702000001_enforce_system_settings_key_not_null.rb +9 -0
  210. data/db/migrate/20260702000002_add_creative_index_and_fk_to_tags.rb +11 -0
  211. data/db/migrate/20260702000003_add_creative_type_index_to_labels.rb +10 -0
  212. data/db/migrate/20260702000004_add_creative_created_at_index_to_comments.rb +10 -0
  213. data/db/migrate/20260702000005_promote_channel_config_index_columns.rb +76 -0
  214. data/db/migrate/20260713000001_add_comments_count_to_creatives.rb +20 -0
  215. data/db/migrate/20260713000002_add_comment_versions_count_to_comments.rb +20 -0
  216. data/db/migrate/20260715000000_change_creatives_description_to_plain_text.rb +16 -0
  217. data/db/seeds.rb +51 -0
  218. data/lib/collavre/version.rb +1 -1
  219. data/lib/generators/collavre/install/install_generator.rb +1 -0
  220. metadata +96 -2
  221. 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,130 @@
1
+ import {
2
+ $createParagraphNode,
3
+ $createTextNode,
4
+ $getSelection,
5
+ $isDecoratorNode,
6
+ $isRangeSelection
7
+ } from "lexical"
8
+ import { $createCodeNode, $isCodeNode } from "@lexical/code"
9
+ import { $isTableNode } from "@lexical/table"
10
+ import { $isListNode } from "@lexical/list"
11
+
12
+ /**
13
+ * Toggle the current selection between a code block and normal paragraphs.
14
+ *
15
+ * Must be called inside an editor.update() callback.
16
+ *
17
+ * Selecting several blocks and hitting the code-block button merges ALL of them
18
+ * into a single code block (one line per source block), instead of only the
19
+ * anchor block — the old single-node behaviour dropped every line but the last.
20
+ *
21
+ * When every selected top-level block is already a code block the toggle runs in
22
+ * reverse: each code block is expanded back into one paragraph per line.
23
+ * Text blocks (headings, quotes, paragraphs) are absorbed as plain text; tables,
24
+ * media, and lists are structural and left in place (see $isMergeableTextBlock).
25
+ */
26
+ export function $toggleCodeBlockForSelection() {
27
+ const selection = $getSelection()
28
+ if (!$isRangeSelection(selection)) return
29
+
30
+ const topLevels = $selectedTopLevelBlocks(selection)
31
+ if (topLevels.length === 0) return
32
+
33
+ // All selected blocks are code → toggle OFF (expand each back to paragraphs).
34
+ if (topLevels.every($isCodeNode)) {
35
+ let lastParagraph = null
36
+ topLevels.forEach((codeNode) => {
37
+ lastParagraph = $expandCodeNodeToParagraphs(codeNode)
38
+ })
39
+ // The code nodes the selection referenced are gone; move the caret to the
40
+ // end of the expanded text so Lexical keeps a valid selection.
41
+ if (lastParagraph) lastParagraph.selectEnd()
42
+ return
43
+ }
44
+
45
+ // Otherwise → fold the selected TEXT blocks into code blocks. Tables and media
46
+ // (image/video/attachment DecoratorNodes) are structural: their text content is
47
+ // empty or a flattening of tabular data, so absorbing them would silently
48
+ // destroy the table/media. They are left in place, which SPLITS the selection:
49
+ // merging text from both sides of a structural block into one code block would
50
+ // pull later content across the block and corrupt document order. So each
51
+ // contiguous run of text blocks becomes its own code block, structural blocks
52
+ // untouched in between (matches the old paragraph-only guard, widened to
53
+ // headings/quotes/lists but not across structural containers).
54
+ let run = []
55
+ let lastCode = null
56
+ const flushRun = () => {
57
+ if (run.length === 0) return
58
+ const content = run.map((node) => node.getTextContent()).join("\n")
59
+ const codeNode = $createCodeNode()
60
+ codeNode.append($createTextNode(content))
61
+ run[0].replace(codeNode)
62
+ for (let index = 1; index < run.length; index += 1) run[index].remove()
63
+ lastCode = codeNode
64
+ run = []
65
+ }
66
+ topLevels.forEach((node) => {
67
+ if ($isMergeableTextBlock(node)) run.push(node)
68
+ else flushRun()
69
+ })
70
+ flushRun()
71
+ if (lastCode) lastCode.selectEnd()
72
+ }
73
+
74
+ // A block can be folded into a code block only if its text content faithfully
75
+ // represents it. Tables (tabular structure) and decorator media (no text) do
76
+ // not, so they are excluded from the merge to avoid destroying content.
77
+ function $isMergeableTextBlock(node) {
78
+ if ($isTableNode(node) || $isDecoratorNode(node)) return false
79
+ // A ListNode is the top-level element for every bullet, so selecting text in
80
+ // ONE bullet top-levels to the whole list. Merging it would flatten every
81
+ // sibling bullet (via getTextContent()) and replace the entire list — losing
82
+ // unselected bullets. Item-granular folding (split the list, keep the rest) is
83
+ // out of scope here, so lists are treated as structural and left in place;
84
+ // convert a list to code via a ``` fence instead.
85
+ if ($isListNode(node)) return false
86
+ // TableCellNode is a shadow root, so getTopLevelElement() on cell content
87
+ // returns the cell's own paragraph — a plain block that slips past the
88
+ // $isTableNode check above. Merging that cell block with document-root blocks
89
+ // moves content across the table boundary (relocating outside text into a
90
+ // cell, or replacing the cell body). Never merge table-scoped blocks.
91
+ if (node.getParents().some($isTableNode)) return false
92
+ return true
93
+ }
94
+
95
+ // Distinct top-level blocks the selection touches, in document order. getNodes()
96
+ // returns the range's nodes in document order; each maps to its owning top-level
97
+ // element. Falls back to the anchor's block for a collapsed selection.
98
+ function $selectedTopLevelBlocks(selection) {
99
+ const seen = new Set()
100
+ const blocks = []
101
+ selection.getNodes().forEach((node) => {
102
+ const topLevel = node.getTopLevelElement()
103
+ if (!topLevel || seen.has(topLevel.getKey())) return
104
+ seen.add(topLevel.getKey())
105
+ blocks.push(topLevel)
106
+ })
107
+ if (blocks.length === 0) {
108
+ const anchorTop = selection.anchor.getNode().getTopLevelElement()
109
+ if (anchorTop) blocks.push(anchorTop)
110
+ }
111
+ return blocks
112
+ }
113
+
114
+ // Replace a code block with one paragraph per line, preserving order. Returns
115
+ // the last paragraph so the caller can restore a valid selection (the code
116
+ // node the selection pointed at is gone once we replace it).
117
+ function $expandCodeNodeToParagraphs(codeNode) {
118
+ const lines = codeNode.getTextContent().split("\n")
119
+ const firstParagraph = $createParagraphNode()
120
+ firstParagraph.append($createTextNode(lines[0] || ""))
121
+ codeNode.replace(firstParagraph)
122
+ let previous = firstParagraph
123
+ for (let index = 1; index < lines.length; index += 1) {
124
+ const paragraph = $createParagraphNode()
125
+ paragraph.append($createTextNode(lines[index]))
126
+ previous.insertAfter(paragraph)
127
+ previous = paragraph
128
+ }
129
+ return previous
130
+ }
@@ -0,0 +1,60 @@
1
+ import {
2
+ $getSelection,
3
+ $isRangeSelection,
4
+ $isParagraphNode,
5
+ KEY_ENTER_COMMAND,
6
+ COMMAND_PRIORITY_HIGH
7
+ } from "lexical"
8
+ import { $createCodeNode } from "@lexical/code"
9
+ import { markLanguageResolved } from "../editor/code_languages"
10
+
11
+ // A paragraph whose entire text is a Markdown fence opener: three backticks,
12
+ // optionally followed by a language token (e.g. ```ruby). Nothing may follow
13
+ // the language, so normal prose that merely contains backticks is untouched.
14
+ const FENCE_REGEX = /^```([\w+-]*)$/
15
+
16
+ /**
17
+ * Open a code block when the user presses Enter on a "```" (optionally
18
+ * "```lang") line. The built-in @lexical/markdown CODE transformer only fires on
19
+ * "``` " + a trailing space, so Enter after the fence (what users actually do)
20
+ * left the paragraph as plain text; this adds the Enter path.
21
+ *
22
+ * Enter is used deliberately rather than reacting to the fence text itself:
23
+ * converting the moment the third backtick lands would swallow "```json" before
24
+ * the language could be typed. Conversion must wait for the delimiter — a space
25
+ * (handled by the built-in transformer) or Enter (handled here) — so whatever
26
+ * language the user typed between the fence and the delimiter is captured.
27
+ *
28
+ * Returns the editor.registerCommand teardown so callers can clean up.
29
+ */
30
+ export function registerCodeFenceShortcut(editor) {
31
+ return editor.registerCommand(
32
+ KEY_ENTER_COMMAND,
33
+ (event) => {
34
+ // Shift+Enter is a soft newline within a block, never a fence commit.
35
+ if (event?.shiftKey) return false
36
+
37
+ const selection = $getSelection()
38
+ if (!$isRangeSelection(selection) || !selection.isCollapsed()) return false
39
+
40
+ const paragraph = selection.anchor.getNode().getTopLevelElement()
41
+ if (!$isParagraphNode(paragraph)) return false
42
+
43
+ const match = paragraph.getTextContent().match(FENCE_REGEX)
44
+ if (!match) return false
45
+
46
+ // Swallow the newline Enter would otherwise insert; replace the fence line.
47
+ event?.preventDefault()
48
+ const language = match[1] || undefined
49
+ const codeNode = $createCodeNode(language)
50
+ paragraph.replace(codeNode)
51
+ // An explicit fence language must survive the highlight transform, which
52
+ // otherwise re-detects any block still on the "javascript" default and would
53
+ // silently relabel a deliberate ```javascript. Mirror the import path.
54
+ if (language) markLanguageResolved(editor, codeNode.getKey())
55
+ codeNode.selectStart()
56
+ return true
57
+ },
58
+ COMMAND_PRIORITY_HIGH
59
+ )
60
+ }