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,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
+ }
@@ -0,0 +1,320 @@
1
+ import {
2
+ $isTextNode,
3
+ $isParagraphNode,
4
+ $isLineBreakNode,
5
+ $createParagraphNode
6
+ } from "lexical"
7
+ import { $convertToMarkdownString, TRANSFORMERS } from "@lexical/markdown"
8
+ import { TABLE, setCellTransformers } from "./table_transformer"
9
+
10
+ // Serializes the Lexical editor state to Markdown as the canonical storage
11
+ // format. Standard block/inline features (headings, lists, quotes, code,
12
+ // bold/italic/strikethrough, inline code, links) round-trip through Markdown
13
+ // via the upstream TRANSFORMERS. Features Markdown can't express are emitted as
14
+ // a SMALL, FIXED set of normalized inline HTML fragments:
15
+ //
16
+ // - text color / background-color -> <span style="...">
17
+ // - images / videos / attachments -> raw <img>/<video>/<a download> tags
18
+ //
19
+ // The HTML shape is normalized (one canonical form per feature) and the CSS
20
+ // values are validated, so the server-side sanitizer can safelist exactly these
21
+ // and the import path (markdown -> HTML -> Lexical) reconstructs them losslessly.
22
+
23
+ // Only these CSS color values are allowed inside an emitted <span>. Anything
24
+ // else (url(), expression(), javascript:, <, >) is dropped so a crafted inline
25
+ // style can't smuggle script or external requests into stored Markdown.
26
+ const SAFE_COLOR_VALUE =
27
+ /^(#[0-9a-fA-F]{3,8}|rgba?\([0-9.,%\s]+\)|hsla?\([0-9.,%\s]+\)|var\(--[a-zA-Z0-9-_]+\)|[a-zA-Z]+)$/
28
+
29
+ function safeColorValue(value) {
30
+ if (!value) return null
31
+ const v = value.trim().replace(/;+$/, "").trim()
32
+ if (!v) return null
33
+ if (/url\(|expression|javascript:|@import|[<>]/i.test(v)) return null
34
+ return SAFE_COLOR_VALUE.test(v) ? v : null
35
+ }
36
+
37
+ function colorBgFromStyle(styleText) {
38
+ let color = null
39
+ let background = null
40
+ ;(styleText || "").split(";").forEach((decl) => {
41
+ const idx = decl.indexOf(":")
42
+ if (idx === -1) return
43
+ const key = decl.slice(0, idx).trim().toLowerCase()
44
+ const value = decl.slice(idx + 1)
45
+ if (key === "color") color = safeColorValue(value)
46
+ else if (key === "background-color") background = safeColorValue(value)
47
+ })
48
+ return { color, background }
49
+ }
50
+
51
+ function escapeHtmlAttr(value) {
52
+ return String(value == null ? "" : value)
53
+ .replace(/&/g, "&amp;")
54
+ .replace(/"/g, "&quot;")
55
+ .replace(/</g, "&lt;")
56
+ .replace(/>/g, "&gt;")
57
+ }
58
+
59
+ function escapeHtmlText(value) {
60
+ return String(value == null ? "" : value)
61
+ .replace(/&/g, "&amp;")
62
+ .replace(/</g, "&lt;")
63
+ .replace(/>/g, "&gt;")
64
+ }
65
+
66
+ // Wrap already-formatted inner Markdown text in a normalized colored <span>, or
67
+ // return null when the style carries no (safe) color/background. The declaration
68
+ // order is fixed (color first) so the same selection always serializes to the
69
+ // same bytes — line-diff/snapshot stability depends on this.
70
+ export function colorSpanMarkup(styleText, inner) {
71
+ const { color, background } = colorBgFromStyle(styleText)
72
+ if (!color && !background) return null
73
+ const decls = []
74
+ if (color) decls.push(`color: ${color}`)
75
+ if (background) decls.push(`background-color: ${background}`)
76
+ // `inner` is the Markdown-formatted text of a colored node. Lexical's export
77
+ // escapes Markdown punctuation but NOT HTML metacharacters, so colored text
78
+ // like `<foo>` would become raw markup inside the <span> and get reinterpreted
79
+ // (and stripped) by the Markdown renderer/sanitizer. Escape <, >, & so user
80
+ // text stays text. Markdown syntax chars (*, _, `, ~) are left untouched.
81
+ return `<span style="${decls.join("; ")}">${escapeHtmlText(inner)}</span>`
82
+ }
83
+
84
+ // Canonical inline HTML for the decorator nodes, matching the shapes the import
85
+ // path (markdown_to_html -> $generateNodesFromDOM) and the server sanitizer
86
+ // expect. Numeric width/height only — Lexical's "inherit" sentinel is dropped.
87
+ export function imageMarkup({ src, altText, width, height }) {
88
+ let html = `<img src="${escapeHtmlAttr(src)}" alt="${escapeHtmlAttr(altText)}"`
89
+ if (Number.isFinite(Number(width)) && Number(width) > 0) {
90
+ html += ` width="${escapeHtmlAttr(width)}"`
91
+ }
92
+ if (Number.isFinite(Number(height)) && Number(height) > 0) {
93
+ html += ` height="${escapeHtmlAttr(height)}"`
94
+ }
95
+ return `${html}>`
96
+ }
97
+
98
+ export function videoMarkup({ src }) {
99
+ return `<video controls src="${escapeHtmlAttr(src)}"></video>`
100
+ }
101
+
102
+ export function attachmentMarkup({ src, filename, filesize }) {
103
+ let html = `<a href="${escapeHtmlAttr(src)}" download="${escapeHtmlAttr(filename)}"`
104
+ if (filesize != null && filesize !== "") {
105
+ html += ` data-filesize="${escapeHtmlAttr(filesize)}"`
106
+ }
107
+ return `${html}>${escapeHtmlText(filename)}</a>`
108
+ }
109
+
110
+ // Boilerplate so a transformer can be export-only: import never fires.
111
+ const NEVER = /(?!)/
112
+
113
+ function exportOnlyTransformer(exportFn, { dependencies = [], type = "text-match" } = {}) {
114
+ return {
115
+ dependencies,
116
+ export: exportFn,
117
+ importRegExp: NEVER,
118
+ regExp: NEVER,
119
+ replace: () => false,
120
+ trigger: "",
121
+ type
122
+ }
123
+ }
124
+
125
+ // Decorator nodes -> raw HTML. Duck-typed via getType() so this module stays
126
+ // free of the .jsx node classes (kept importable under native-ESM Jest).
127
+ function decoratorMarkup(node) {
128
+ const type = node.getType ? node.getType() : null
129
+ if (type === "image") {
130
+ return imageMarkup({
131
+ src: node.getSrc?.(),
132
+ altText: node.getAltText?.(),
133
+ width: node.__width,
134
+ height: node.__height
135
+ })
136
+ }
137
+ if (type === "video") {
138
+ return videoMarkup({ src: node.getSrc?.() })
139
+ }
140
+ if (type === "attachment") {
141
+ return attachmentMarkup({
142
+ src: node.getSrc?.(),
143
+ filename: node.getFilename?.(),
144
+ filesize: node.__filesize
145
+ })
146
+ }
147
+ return null
148
+ }
149
+
150
+ // A paragraph the user left blank: it carries no real text, only empty text
151
+ // nodes and/or line breaks. (Decorator- or text-bearing paragraphs are NOT
152
+ // blank, so media and real content keep their normal export.) An empty
153
+ // paragraph from pressing Enter has zero children; a run of blank lines that
154
+ // was reopened comes back as ONE paragraph holding N LineBreakNodes (the
155
+ // importer groups consecutive <br> markers together).
156
+ function isBlankParagraph(node) {
157
+ if (!$isParagraphNode(node)) return false
158
+ // A blank paragraph inside a table cell is just an empty cell — it must stay
159
+ // empty (`| |`), not become a `<br>`. The cell serializer reuses this
160
+ // transformer set, so exclude paragraphs nested in a cell (duck-typed by type
161
+ // to keep this module free of the @lexical/table import).
162
+ const parent = node.getParent ? node.getParent() : null
163
+ if (parent && parent.getType && parent.getType() === "tablecell") return false
164
+ return node
165
+ .getChildren()
166
+ .every((child) => $isLineBreakNode(child) || ($isTextNode(child) && !child.getTextContent().trim()))
167
+ }
168
+
169
+ // How many blank lines a blank paragraph represents: one per LineBreakNode, but
170
+ // at least one (a freshly-typed empty paragraph has no line breaks yet still
171
+ // stands for a single blank line). This exact count is what keeps multiple
172
+ // blank lines from multiplying on every save — the reopened paragraph holds N
173
+ // line breaks and must re-emit N markers, not N+1.
174
+ function blankParagraphBreakCount(node) {
175
+ const breaks = node.getChildren().filter($isLineBreakNode).length
176
+ return Math.max(1, breaks)
177
+ }
178
+
179
+ // Blank paragraphs -> one `<br>` per blank line. A `<br>` is a semantic empty
180
+ // line (not a stray space or NBSP): the hard-break renderers keep it as a
181
+ // visible blank line, and because each marker sits in its own block separated
182
+ // by the standard `\n\n`, a blank line after a list no longer gets pulled into
183
+ // the last <li> via lazy continuation. Round-trips: the importer regroups the
184
+ // rendered <br> elements into a blank paragraph that re-exports identically.
185
+ const BLANK_PARAGRAPH_TRANSFORMER = exportOnlyTransformer(
186
+ (node) =>
187
+ isBlankParagraph(node) ? Array(blankParagraphBreakCount(node)).fill("<br>").join("\n\n") : null,
188
+ { type: "element" }
189
+ )
190
+
191
+ // Colored / highlighted text -> normalized <span>. Falls through (returns null)
192
+ // for uncolored text so the default text-format export still applies.
193
+ const COLOR_TRANSFORMER = exportOnlyTransformer((node, _exportChildren, exportFormat) => {
194
+ if (!$isTextNode(node)) return null
195
+ const style = node.getStyle ? node.getStyle() : ""
196
+ if (!style) return null
197
+ return colorSpanMarkup(style, exportFormat(node, node.getTextContent()))
198
+ })
199
+
200
+ // Decorator handler registered as a TEXT-MATCH transformer for media that lives
201
+ // INLINE inside a paragraph (claimed during exportChildren).
202
+ const DECORATOR_TEXT_TRANSFORMER = exportOnlyTransformer((node) => decoratorMarkup(node))
203
+
204
+ // The SAME handler registered as an ELEMENT transformer for media that is a
205
+ // direct child of the root (the upload plugin's no-selection append path, and
206
+ // imported block-level <img> nodes). $convertToMarkdownString only runs element
207
+ // transformers on top-level nodes — a top-level decorator that matches no element
208
+ // transformer falls back to DecoratorNode#getTextContent() (empty for media),
209
+ // silently dropping it from markdown_source. Without this, the first rich save
210
+ // loses every top-level image/video/file.
211
+ const DECORATOR_ELEMENT_TRANSFORMER = exportOnlyTransformer((node) => decoratorMarkup(node), {
212
+ type: "element"
213
+ })
214
+
215
+ // Our custom transformers run first so colored text and decorator nodes are
216
+ // claimed before the upstream defaults (which would drop their style/content).
217
+ export const MARKDOWN_TRANSFORMERS = [
218
+ TABLE,
219
+ DECORATOR_ELEMENT_TRANSFORMER,
220
+ BLANK_PARAGRAPH_TRANSFORMER,
221
+ DECORATOR_TEXT_TRANSFORMER,
222
+ COLOR_TRANSFORMER,
223
+ ...TRANSFORMERS
224
+ ]
225
+
226
+ // Tables serialize their cell content with the same transformer set. Injected
227
+ // here (rather than imported into table_transformer.js) to break the cycle.
228
+ setCellTransformers(MARKDOWN_TRANSFORMERS)
229
+
230
+ // Fenced code blocks may legitimately contain consecutive blank lines; guard
231
+ // them so the blank-line normalization below never touches a code sample.
232
+ const FENCE_BLOCK = /(`{3,}|~{3,})[^\n]*\n[\s\S]*?\n\1[ \t]*(?=\n|$)/g
233
+
234
+ // Inline code spans (`...`) may legitimately contain the literal text `<br>` or
235
+ // a lone NBSP; guard them too so the blank-line normalization never rewrites the
236
+ // span (which would break it on save). Matched after fences are stashed, so the
237
+ // remaining backtick runs are genuine inline code.
238
+ const INLINE_CODE = /(`+)[^\n]*?\1/g
239
+
240
+ // Normalize the canonical Markdown projection. Enter produces a real paragraph
241
+ // break (standard `\n\n` separation); a blank line the user typed is preserved
242
+ // as a `<br>` marker (see BLANK_PARAGRAPH_TRANSFORMER). This pass only:
243
+ // - returns "" for a document with no real content (only whitespace and/or
244
+ // `<br>` markers), keeping the empty-state placeholder/presence contract,
245
+ // - migrates legacy NBSP-only blank-line markers (pre-`<br>` content) to a
246
+ // real `<br>` marker so they stop acting as a CommonMark lazy continuation,
247
+ // - isolates every `<br>` marker as its own block,
248
+ // - collapses runs of 3+ newlines to one blank line so block separation stays
249
+ // canonical and the very first save is round-trip stable,
250
+ // - trims trailing whitespace.
251
+ // Blank lines and literal `<br>`/NBSP inside code (fenced or inline) are
252
+ // preserved verbatim.
253
+ export function normalizeMarkdownBlankLines(markdown) {
254
+ // A document of only blank lines (each now a `<br>`) carries no real content,
255
+ // so strip the markers before the emptiness check.
256
+ if (!String(markdown).replace(/<br\s*\/?>/gi, "").trim()) return ""
257
+
258
+ const guards = []
259
+ const stash = (match) => `\x00MDGUARD${guards.push(match) - 1}\x00`
260
+ const guarded = String(markdown)
261
+ .replace(FENCE_BLOCK, stash)
262
+ .replace(INLINE_CODE, stash)
263
+
264
+ const normalized = guarded
265
+ // Legacy blank-line markers stored a line of only NBSP (U+00A0). CommonMark
266
+ // treats NBSP as content — a lazy list continuation — re-introducing the
267
+ // list-indent bug, so migrate such lines to a real `<br>` marker on save.
268
+ // (A regular whitespace-only line is already a CommonMark blank line.)
269
+ .replace(/^[ \t]*\u00A0[ \t\u00A0]*$/gm, "<br>")
270
+ // Each blank-line marker is its own block: isolate every `<br>` with a blank
271
+ // line on both sides. Lexical's exporter joins a freshly-typed empty
272
+ // paragraph to its neighbours with a single `\n`, while a reopened (grouped)
273
+ // blank paragraph gets `\n\n`; canonicalizing here makes the very first save
274
+ // byte-identical to the reopen fixpoint regardless of which path produced it.
275
+ // BLANK_PARAGRAPH_TRANSFORMER always emits a marker alone on its line, so we
276
+ // only match a `<br>` that is the WHOLE line (anchored ^…$ under /m). A
277
+ // `<br>` embedded in other content (an in-paragraph hard break or raw inline
278
+ // HTML such as `<span>foo<br>bar</span>`) is not a marker and is left
279
+ // untouched — isolating it would rewrite user HTML on save.
280
+ .replace(/\n*^[ \t]*<br>[ \t]*$\n*/gm, "\n\n<br>\n\n")
281
+ .replace(/\n{3,}/g, "\n\n")
282
+ .replace(/^\n+/, "")
283
+ .replace(/\s+$/, "")
284
+
285
+ return normalized.replace(/\x00MDGUARD(\d+)\x00/g, (_, n) => guards[Number(n)])
286
+ }
287
+
288
+ // On reopen, a run of blank-line markers (<br>) comes back grouped into ONE
289
+ // paragraph holding N LineBreakNodes. Lexical renders such a paragraph as N+1
290
+ // visual lines — each break starts a new line on top of the paragraph's own
291
+ // line — so a single typed blank line reopens as TWO, growing by one on every
292
+ // reopen (the "blank lines keep multiplying" bug). Freshly typed blank lines are EMPTY
293
+ // paragraphs instead (zero children, one visual line each). Re-create that
294
+ // structure: replace every all-LineBreakNode paragraph with N empty paragraphs
295
+ // so reopened blank lines match typed ones. Export is unaffected — each empty
296
+ // paragraph still emits exactly one <br> (blankParagraphBreakCount), so the
297
+ // canonical Markdown round-trips byte-for-byte. Paragraphs that mix text with a
298
+ // break (real soft breaks) are left untouched.
299
+ export function splitBlankLineParagraphs(root) {
300
+ if (!root || !root.getChildren) return
301
+ root.getChildren().forEach((node) => {
302
+ if (!$isParagraphNode(node)) return
303
+ const children = node.getChildren()
304
+ if (children.length === 0) return
305
+ if (!children.every($isLineBreakNode)) return
306
+ for (let i = 0; i < children.length; i++) {
307
+ node.insertBefore($createParagraphNode())
308
+ }
309
+ node.remove()
310
+ })
311
+ }
312
+
313
+ // Read the editor state and return canonical Markdown.
314
+ export function lexicalToMarkdown(editor) {
315
+ let markdown = ""
316
+ editor.getEditorState().read(() => {
317
+ markdown = $convertToMarkdownString(MARKDOWN_TRANSFORMERS)
318
+ })
319
+ return normalizeMarkdownBlankLines(markdown)
320
+ }
@@ -0,0 +1,58 @@
1
+ import {
2
+ $isRangeSelection,
3
+ $isTextNode,
4
+ $isRootOrShadowRoot
5
+ } from "lexical"
6
+
7
+ // True when a collapsed selection sits at the very start of the whole document
8
+ // (offset 0 of the first leaf, with no previous siblings up the tree).
9
+ //
10
+ // NOTE: checking `$getCharacterOffsets() === [0, 0]` is NOT enough — that only
11
+ // reports the offset within the current node, so the start of the *second*
12
+ // paragraph (e.g. right after pressing Enter) also reads as [0, 0]. We must walk
13
+ // up the tree confirming there is no earlier content, mirroring
14
+ // isSelectionAtDocumentEnd.
15
+ export function isSelectionAtDocumentStart(selection) {
16
+ if (!$isRangeSelection(selection) || !selection.isCollapsed()) return false
17
+
18
+ const anchor = selection.anchor
19
+ let node = anchor.getNode()
20
+ if (!node) return false
21
+
22
+ if (anchor.offset !== 0) return false
23
+
24
+ while (node && !$isRootOrShadowRoot(node)) {
25
+ if (node.getPreviousSibling()) return false
26
+ node = node.getParent()
27
+ }
28
+
29
+ return !!node && $isRootOrShadowRoot(node)
30
+ }
31
+
32
+ // True when a collapsed selection sits at the very end of the whole document
33
+ // (end of the last leaf, with no following siblings up the tree).
34
+ export function isSelectionAtDocumentEnd(selection) {
35
+ if (!$isRangeSelection(selection) || !selection.isCollapsed()) return false
36
+
37
+ const focus = selection.focus
38
+ let node = focus.getNode()
39
+ if (!node) return false
40
+
41
+ const offset = focus.offset
42
+ if ($isTextNode(node)) {
43
+ if (offset !== node.getTextContentSize()) return false
44
+ } else if (typeof node.getChildrenSize === "function") {
45
+ if (offset !== node.getChildrenSize()) return false
46
+ } else {
47
+ // Fallback for nodes without children size (e.g., line breaks)
48
+ const textSize = node.getTextContentSize?.() ?? 0
49
+ if (offset !== textSize) return false
50
+ }
51
+
52
+ while (node && !$isRootOrShadowRoot(node)) {
53
+ if (node.getNextSibling()) return false
54
+ node = node.getParent()
55
+ }
56
+
57
+ return !!node && $isRootOrShadowRoot(node)
58
+ }