collavre 0.21.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 (200) 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 +169 -64
  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/popup.css +148 -0
  9. data/app/assets/stylesheets/collavre/tables.css +91 -0
  10. data/app/channels/collavre/agent_channel.rb +205 -0
  11. data/app/channels/collavre/inbox_badge_channel.rb +30 -0
  12. data/app/controllers/collavre/admin/integrations_controller.rb +16 -5
  13. data/app/controllers/collavre/api/v1/agents_controller.rb +777 -0
  14. data/app/controllers/collavre/api/v1/base_controller.rb +46 -0
  15. data/app/controllers/collavre/api/v1/mobile/agent_events_controller.rb +224 -0
  16. data/app/controllers/collavre/api/v1/mobile/base_controller.rb +95 -0
  17. data/app/controllers/collavre/api/v1/mobile/devices_controller.rb +31 -0
  18. data/app/controllers/collavre/api/v1/mobile/voice_commands_controller.rb +25 -0
  19. data/app/controllers/collavre/attachments_controller.rb +30 -2
  20. data/app/controllers/collavre/comments_controller.rb +1 -1
  21. data/app/controllers/collavre/creatives/attachments_controller.rb +79 -0
  22. data/app/controllers/collavre/creatives_controller.rb +107 -6
  23. data/app/controllers/collavre/tasks_controller.rb +21 -4
  24. data/app/controllers/collavre/topics_controller.rb +64 -1
  25. data/app/controllers/collavre/typo_corrections_controller.rb +39 -0
  26. data/app/controllers/concerns/collavre/comments/approval_actions.rb +57 -14
  27. data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +16 -1
  28. data/app/controllers/concerns/collavre/users_controller/registration.rb +41 -1
  29. data/app/helpers/collavre/application_helper.rb +1 -0
  30. data/app/javascript/collavre.js +2 -0
  31. data/app/javascript/components/ImageResizer.jsx +9 -3
  32. data/app/javascript/components/InlineLexicalEditor.jsx +196 -96
  33. data/app/javascript/components/creative_tree_row.js +20 -3
  34. data/app/javascript/components/plugins/attachment_cleanup_plugin.jsx +3 -0
  35. data/app/javascript/components/plugins/image_upload_plugin.jsx +12 -0
  36. data/app/javascript/components/plugins/list_tab_indent_plugin.jsx +16 -0
  37. data/app/javascript/components/plugins/table_hover_actions_plugin.jsx +405 -0
  38. data/app/javascript/controllers/__tests__/inbox_badge_controller.test.js +73 -0
  39. data/app/javascript/controllers/__tests__/link_creative_controller.test.js +447 -0
  40. data/app/javascript/controllers/comment_controller.js +11 -5
  41. data/app/javascript/controllers/comment_version_controller.js +2 -1
  42. data/app/javascript/controllers/comments/__tests__/form_controller_double_submit.test.js +159 -0
  43. data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +111 -2
  44. data/app/javascript/controllers/comments/__tests__/topics_controller_delete.test.js +94 -0
  45. data/app/javascript/controllers/comments/form_controller.js +21 -5
  46. data/app/javascript/controllers/comments/list_controller.js +35 -19
  47. data/app/javascript/controllers/comments/presence_controller.js +58 -6
  48. data/app/javascript/controllers/comments/topics_controller.js +14 -8
  49. data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +150 -0
  50. data/app/javascript/controllers/creatives/import_controller.js +2 -1
  51. data/app/javascript/controllers/creatives/select_mode_controller.js +2 -1
  52. data/app/javascript/controllers/creatives/tree_controller.js +142 -1
  53. data/app/javascript/controllers/image_lightbox_controller.js +2 -1
  54. data/app/javascript/controllers/inbox_badge_controller.js +33 -0
  55. data/app/javascript/controllers/index.js +4 -1
  56. data/app/javascript/controllers/link_creative_controller.js +451 -29
  57. data/app/javascript/controllers/share_modal_controller.js +4 -3
  58. data/app/javascript/controllers/topic_search_controller.js +2 -1
  59. data/app/javascript/creatives/drag_drop/event_handlers.js +14 -5
  60. data/app/javascript/creatives/topic_move_members_popup.js +156 -0
  61. data/app/javascript/creatives/tree_renderer.js +11 -0
  62. data/app/javascript/lib/__tests__/turbo_confirm.test.js +81 -0
  63. data/app/javascript/lib/__tests__/typo_correction.test.js +192 -0
  64. data/app/javascript/lib/api/__tests__/api_error.test.js +96 -0
  65. data/app/javascript/lib/api/__tests__/queue_manager.test.js +88 -1
  66. data/app/javascript/lib/api/api_error.js +108 -0
  67. data/app/javascript/lib/api/creatives.js +13 -0
  68. data/app/javascript/lib/api/queue_manager.js +38 -4
  69. data/app/javascript/lib/common_popup.js +18 -5
  70. data/app/javascript/lib/editor/__tests__/code_edit_view_token_parity.test.js +121 -0
  71. data/app/javascript/lib/editor/__tests__/code_language_roundtrip.test.js +152 -0
  72. data/app/javascript/lib/editor/__tests__/code_languages.test.js +93 -0
  73. data/app/javascript/lib/editor/code_languages.js +173 -0
  74. data/app/javascript/lib/editor/code_token_theme.js +41 -0
  75. data/app/javascript/lib/lexical/__tests__/color_import.test.js +318 -0
  76. data/app/javascript/lib/lexical/__tests__/image_focus.test.js +139 -0
  77. data/app/javascript/lib/lexical/__tests__/list_tab_indent.test.js +633 -0
  78. data/app/javascript/lib/lexical/__tests__/markdown_serialize.test.js +627 -0
  79. data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +278 -0
  80. data/app/javascript/lib/lexical/__tests__/selection_boundary.test.js +88 -0
  81. data/app/javascript/lib/lexical/__tests__/table_transformer.test.js +163 -0
  82. data/app/javascript/lib/lexical/__tests__/trailing_paragraph.test.js +104 -0
  83. data/app/javascript/lib/lexical/color_import.js +186 -0
  84. data/app/javascript/lib/lexical/list_tab_indent.js +210 -0
  85. data/app/javascript/lib/lexical/markdown_serialize.js +320 -0
  86. data/app/javascript/lib/lexical/minimize_html.js +182 -0
  87. data/app/javascript/lib/lexical/selection_boundary.js +58 -0
  88. data/app/javascript/lib/lexical/table_transformer.js +182 -0
  89. data/app/javascript/lib/lexical/trailing_paragraph.js +29 -0
  90. data/app/javascript/lib/lexical/video_node.jsx +96 -0
  91. data/app/javascript/lib/turbo_confirm.js +46 -0
  92. data/app/javascript/lib/typo_correction.js +146 -0
  93. data/app/javascript/lib/utils/__tests__/confirm_dialog.test.js +88 -0
  94. data/app/javascript/lib/utils/__tests__/dialog.test.js +92 -0
  95. data/app/javascript/lib/utils/__tests__/markdown.test.js +153 -0
  96. data/app/javascript/lib/utils/__tests__/sanitize_description.test.js +68 -0
  97. data/app/javascript/lib/utils/__tests__/table_download.test.js +93 -0
  98. data/app/javascript/lib/utils/confirm_dialog.js +10 -0
  99. data/app/javascript/lib/utils/dialog.js +300 -0
  100. data/app/javascript/lib/utils/markdown.js +154 -67
  101. data/app/javascript/lib/utils/sanitize_description.js +31 -0
  102. data/app/javascript/lib/utils/table_download.js +15 -0
  103. data/app/javascript/modules/__tests__/typo_corrector.test.js +365 -0
  104. data/app/javascript/modules/creative_row_editor.js +110 -70
  105. data/app/javascript/modules/export_to_markdown.js +2 -1
  106. data/app/javascript/modules/lexical_inline_editor.jsx +2 -1
  107. data/app/javascript/modules/slide_view.js +11 -2
  108. data/app/javascript/modules/typo_corrector.js +534 -0
  109. data/app/jobs/collavre/ai_agent_job.rb +92 -3
  110. data/app/jobs/collavre/cancel_offline_delegated_tasks_job.rb +134 -0
  111. data/app/jobs/collavre/claude_channel_presence_job.rb +91 -0
  112. data/app/jobs/collavre/compress_job.rb +6 -2
  113. data/app/models/collavre/agent_subscription.rb +52 -0
  114. data/app/models/collavre/comment/broadcastable.rb +46 -7
  115. data/app/models/collavre/comment/claude_channel_permission.rb +145 -0
  116. data/app/models/collavre/comment/notifiable.rb +14 -4
  117. data/app/models/collavre/comment.rb +124 -11
  118. data/app/models/collavre/creative/describable.rb +220 -4
  119. data/app/models/collavre/creative_share.rb +1 -0
  120. data/app/models/collavre/task.rb +49 -5
  121. data/app/models/collavre/topic.rb +5 -0
  122. data/app/models/collavre/user.rb +61 -1
  123. data/app/services/collavre/agent_session_abort.rb +28 -0
  124. data/app/services/collavre/ai_agent/claude_channel_adapter.rb +79 -0
  125. data/app/services/collavre/ai_agent/response_finalizer.rb +4 -2
  126. data/app/services/collavre/ai_agent_service.rb +68 -49
  127. data/app/services/collavre/ai_client.rb +28 -10
  128. data/app/services/collavre/attachment_backfill.rb +26 -0
  129. data/app/services/collavre/auto_theme_generator.rb +1 -1
  130. data/app/services/collavre/creatives/breadcrumb_resolver.rb +91 -0
  131. data/app/services/collavre/creatives/filter_pipeline.rb +26 -42
  132. data/app/services/collavre/creatives/filters/search_filter.rb +12 -2
  133. data/app/services/collavre/creatives/index_query.rb +195 -24
  134. data/app/services/collavre/creatives/permission_filter.rb +50 -0
  135. data/app/services/collavre/creatives/reveal_path_resolver.rb +118 -0
  136. data/app/services/collavre/creatives/tree_builder.rb +2 -1
  137. data/app/services/collavre/crons/recurring_task_arguments.rb +28 -0
  138. data/app/services/collavre/gemini_parent_recommender.rb +1 -1
  139. data/app/services/collavre/inbox_reply_service.rb +5 -0
  140. data/app/services/collavre/markdown_converter.rb +13 -3
  141. data/app/services/collavre/mobile/event_summarizer.rb +40 -0
  142. data/app/services/collavre/orchestration/agent_orchestrator.rb +33 -7
  143. data/app/services/collavre/orchestration/arbiter.rb +16 -0
  144. data/app/services/collavre/orchestration/matcher.rb +79 -4
  145. data/app/services/collavre/orchestration/policy_resolver.rb +4 -3
  146. data/app/services/collavre/orchestration/stuck_detector.rb +146 -19
  147. data/app/services/collavre/tools/creative_attach_files_service.rb +29 -63
  148. data/app/services/collavre/tools/creative_batch_service.rb +3 -2
  149. data/app/services/collavre/tools/creative_create_service.rb +8 -8
  150. data/app/services/collavre/tools/creative_remove_attachment_service.rb +7 -5
  151. data/app/services/collavre/tools/creative_update_service.rb +23 -8
  152. data/app/services/collavre/tools/cron_list_service.rb +1 -14
  153. data/app/services/collavre/topics/orphaned_cron_notifier.rb +68 -0
  154. data/app/services/collavre/typo_corrector.rb +188 -0
  155. data/app/views/collavre/admin/integrations/_category.html.erb +1 -1
  156. data/app/views/collavre/admin/integrations/_setting_row.html.erb +27 -11
  157. data/app/views/collavre/comments/_comment.html.erb +15 -1
  158. data/app/views/collavre/comments/_comments_popup.html.erb +18 -3
  159. data/app/views/collavre/creatives/_inline_edit_form.html.erb +1 -0
  160. data/app/views/collavre/creatives/_topic_move_members_modal.html.erb +42 -0
  161. data/app/views/collavre/creatives/index.html.erb +14 -1
  162. data/app/views/collavre/creatives/slide_view.html.erb +1 -1
  163. data/app/views/collavre/shared/_link_creative_modal.html.erb +6 -2
  164. data/app/views/collavre/users/show.html.erb +3 -0
  165. data/app/views/collavre/users/typo_correction.html.erb +50 -0
  166. data/app/views/layouts/collavre/slide.html.erb +1 -0
  167. data/config/locales/channels.en.yml +2 -0
  168. data/config/locales/channels.ko.yml +2 -0
  169. data/config/locales/claude_channel.en.yml +16 -0
  170. data/config/locales/claude_channel.ko.yml +16 -0
  171. data/config/locales/comments.en.yml +18 -0
  172. data/config/locales/comments.ko.yml +18 -0
  173. data/config/locales/creatives.en.yml +2 -0
  174. data/config/locales/creatives.ko.yml +2 -0
  175. data/config/locales/integrations.en.yml +13 -2
  176. data/config/locales/integrations.ko.yml +13 -2
  177. data/config/locales/mobile.en.yml +16 -0
  178. data/config/locales/mobile.ko.yml +16 -0
  179. data/config/locales/orchestration.en.yml +1 -0
  180. data/config/locales/orchestration.ko.yml +1 -0
  181. data/config/locales/users.en.yml +15 -0
  182. data/config/locales/users.ko.yml +15 -0
  183. data/config/routes.rb +25 -0
  184. data/db/migrate/20260609000000_drop_action_text_rich_texts.rb +20 -0
  185. data/db/migrate/20260609005000_add_session_id_to_topics.rb +16 -0
  186. data/db/migrate/20260609010000_create_agent_subscriptions.rb +19 -0
  187. data/db/migrate/20260609020000_add_last_seen_at_to_agent_subscriptions.rb +23 -0
  188. data/db/migrate/20260609030000_add_session_id_to_agent_subscriptions.rb +17 -0
  189. data/db/migrate/20260609190659_backfill_creative_files_into_description.rb +24 -0
  190. data/db/migrate/20260612000000_add_topic_concurrency_defer_to_comments.rb +38 -0
  191. data/db/migrate/20260617090000_add_typo_correction_settings_to_users.rb +18 -0
  192. data/db/seeds.rb +51 -0
  193. data/lib/collavre/engine.rb +0 -1
  194. data/lib/collavre/integration_settings/key_definition.rb +6 -0
  195. data/lib/collavre/integration_settings/registry.rb +7 -2
  196. data/lib/collavre/version.rb +1 -1
  197. data/lib/generators/collavre/install/install_generator.rb +1 -0
  198. metadata +85 -3
  199. data/app/services/collavre/openclaw_abort_service.rb +0 -45
  200. data/app/services/collavre/tools/description_normalizable.rb +0 -16
@@ -21,12 +21,16 @@ import {
21
21
  } from "@lexical/code"
22
22
  import { ListItemNode, ListNode, $isListItemNode, $isListNode, INSERT_ORDERED_LIST_COMMAND, INSERT_UNORDERED_LIST_COMMAND } from "@lexical/list"
23
23
  import { $createLinkNode, LinkNode, AutoLinkNode, TOGGLE_LINK_COMMAND } from "@lexical/link"
24
+ import { TableNode, TableRowNode, TableCellNode, INSERT_TABLE_COMMAND } from "@lexical/table"
25
+ import { TablePlugin } from "@lexical/react/LexicalTablePlugin"
26
+ import TableHoverActionsPlugin from "./plugins/table_hover_actions_plugin"
24
27
  import {
25
28
  $createParagraphNode,
26
29
  $createTextNode,
27
30
  $getRoot,
28
31
  $getSelection,
29
32
  $isElementNode,
33
+ $isLineBreakNode,
30
34
  $isRangeSelection,
31
35
  $isTextNode,
32
36
  CAN_REDO_COMMAND,
@@ -47,10 +51,23 @@ import FileUploadPlugin, {
47
51
  } from "./plugins/image_upload_plugin"
48
52
  import { ImageNode } from "../lib/lexical/image_node"
49
53
  import { AttachmentNode } from "../lib/lexical/attachment_node"
54
+ import { VideoNode } from "../lib/lexical/video_node"
50
55
  import AttachmentCleanupPlugin from "./plugins/attachment_cleanup_plugin"
51
56
  import MarkdownShortcutsPlugin from "./plugins/markdown_shortcuts_plugin"
57
+ import ListTabIndentPlugin from "./plugins/list_tab_indent_plugin"
52
58
  import { syncLexicalStyleAttributes } from "../lib/lexical/style_attributes"
59
+ import { lexicalHtmlConfig, normalizeColoredContainers } from "../lib/lexical/color_import"
60
+ import { minimizeContentHtml } from "../lib/lexical/minimize_html"
61
+ import { ensureTrailingParagraph, registerTrailingParagraph } from "../lib/lexical/trailing_paragraph"
62
+ import {
63
+ MARKDOWN_TRANSFORMERS,
64
+ normalizeMarkdownBlankLines,
65
+ splitBlankLineParagraphs
66
+ } from "../lib/lexical/markdown_serialize"
67
+ import { $convertToMarkdownString } from "@lexical/markdown"
53
68
  import { updateResponsiveImages } from "../lib/responsive_images"
69
+ import { CODE_TOKEN_THEME } from "../lib/editor/code_token_theme"
70
+ import { detectCodeLanguage, normalizeFenceLang, bridgeCodeFenceLanguages, markLanguageResolved, isLanguageResolved, clearLanguageResolved } from "../lib/editor/code_languages"
54
71
 
55
72
  const URL_MATCHERS = [
56
73
  createLinkMatcherWithRegExp(/https?:\/\/[^\s<]+/gi, (text) => text)
@@ -67,40 +84,14 @@ const theme = {
67
84
  list: {
68
85
  ul: "lexical-list-ul",
69
86
  ol: "lexical-list-ol",
70
- listitem: "lexical-list-item"
87
+ listitem: "lexical-list-item",
88
+ // Tag the wrapper <li> that only holds a nested list so its bullet marker
89
+ // can be hidden — without this Lexical reuses the plain item class and the
90
+ // empty wrapper renders a stray bullet above the indented sub-list.
91
+ nested: { listitem: "lexical-nested-list-item" }
71
92
  },
72
93
  code: "lexical-code-block",
73
- codeHighlight: {
74
- atrule: "lexical-token-atrule",
75
- attr: "lexical-token-attr",
76
- boolean: "lexical-token-boolean",
77
- builtin: "lexical-token-builtin",
78
- cdata: "lexical-token-cdata",
79
- char: "lexical-token-char",
80
- class: "lexical-token-class",
81
- comment: "lexical-token-comment",
82
- constant: "lexical-token-constant",
83
- deleted: "lexical-token-deleted",
84
- doctype: "lexical-token-doctype",
85
- entity: "lexical-token-entity",
86
- function: "lexical-token-function",
87
- important: "lexical-token-important",
88
- inserted: "lexical-token-inserted",
89
- keyword: "lexical-token-keyword",
90
- namespace: "lexical-token-namespace",
91
- number: "lexical-token-number",
92
- operator: "lexical-token-operator",
93
- prolog: "lexical-token-prolog",
94
- property: "lexical-token-property",
95
- punctuation: "lexical-token-punctuation",
96
- regex: "lexical-token-regex",
97
- selector: "lexical-token-selector",
98
- string: "lexical-token-string",
99
- symbol: "lexical-token-symbol",
100
- tag: "lexical-token-tag",
101
- url: "lexical-token-url",
102
- variable: "lexical-token-variable"
103
- },
94
+ codeHighlight: CODE_TOKEN_THEME,
104
95
  link: "lexical-link",
105
96
  text: {
106
97
  bold: "lexical-text-bold",
@@ -108,7 +99,18 @@ const theme = {
108
99
  underline: "lexical-text-underline",
109
100
  strikethrough: "lexical-text-strike",
110
101
  code: "lexical-text-code"
111
- }
102
+ },
103
+ table: "lexical-table",
104
+ tableScrollableWrapper: "lexical-table-wrapper",
105
+ tableRow: "lexical-table-row",
106
+ tableCell: "lexical-table-cell",
107
+ tableCellHeader: "lexical-table-cell-header",
108
+ tableSelected: "lexical-table-selected",
109
+ tableSelection: "lexical-table-selection",
110
+ tableAddRows: "lexical-table-add-rows",
111
+ tableAddColumns: "lexical-table-add-columns",
112
+ tableDeleteRows: "lexical-table-delete-rows",
113
+ tableDeleteColumns: "lexical-table-delete-columns"
112
114
  }
113
115
 
114
116
  function Placeholder({ text }) {
@@ -120,48 +122,14 @@ function InitialContentPlugin({ html }) {
120
122
  const [editor] = useLexicalComposerContext()
121
123
  const lastApplied = useRef(null)
122
124
 
123
- const collectDomTextStyles = useCallback((container) => {
124
- const styles = []
125
- if (!container) return styles
126
- const ownerDocument = container.ownerDocument || document
127
- const walker = ownerDocument.createTreeWalker(container, NodeFilter.SHOW_TEXT)
128
- let current = walker.nextNode()
129
- while (current) {
130
- const parent = current.parentElement
131
- let styleText = parent?.getAttribute?.("style") || ""
132
- const colorAttr = parent?.dataset?.lexicalColor
133
- const bgAttr = parent?.dataset?.lexicalBackgroundColor
134
-
135
- if ((!styleText || !styleText.trim()) && (colorAttr || bgAttr)) {
136
- const declarations = []
137
- if (colorAttr) declarations.push(`color: ${colorAttr}`)
138
- if (bgAttr) declarations.push(`background-color: ${bgAttr}`)
139
- styleText = declarations.join("; ")
140
- } else {
141
- const lower = styleText.toLowerCase()
142
- const fragments = []
143
- if (colorAttr && !lower.includes("color:")) {
144
- fragments.push(`color: ${colorAttr}`)
145
- }
146
- if (bgAttr && !lower.includes("background-color:")) {
147
- fragments.push(`background-color: ${bgAttr}`)
148
- }
149
- if (fragments.length > 0) {
150
- styleText = `${styleText}${styleText.trim().endsWith(";") || !styleText.trim() ? "" : ";"} ${fragments.join("; ")}`.trim()
151
- }
152
- }
153
-
154
- styles.push(styleText || "")
155
- current = walker.nextNode()
156
- }
157
- return styles
158
- }, [])
159
-
160
125
  useEffect(() => {
161
126
  if (lastApplied.current === html) return
162
127
  lastApplied.current = html
163
128
  editor.update(() => {
164
129
  const root = $getRoot()
130
+ // Re-importing replaces the tree; drop stale resolved-language keys so the
131
+ // registry only tracks nodes from this import.
132
+ clearLanguageResolved(editor)
165
133
  // Explicitly remove all children to ensure it's empty
166
134
  root.getChildren().forEach((child) => child.remove())
167
135
 
@@ -170,10 +138,43 @@ function InitialContentPlugin({ html }) {
170
138
  // No more .trix-content wrapper
171
139
  const container = doc.body
172
140
 
141
+ // @lexical/code's importer only reads `data-language`, but the language is
142
+ // encoded differently depending on which renderer produced this HTML:
143
+ // commonmarker (server reopen) uses `<pre lang>`, while renderMarkdown (the
144
+ // markdown→rich toggle) puts it on `<pre><code class="language-X">`. Bridge
145
+ // both onto `data-language` so an explicit fence language survives reopen
146
+ // instead of being dropped (and then defaulted to javascript). Detection
147
+ // still corrects unlabeled blocks.
148
+ bridgeCodeFenceLanguages(container)
149
+
150
+ // Color / background-color are bound to text nodes during import by the
151
+ // colorAwareSpanImport html config (see lib/lexical/color_import). We no
152
+ // longer re-apply styles positionally after import, which used to drift
153
+ // onto the wrong text node whenever Lexical split or dropped text nodes.
173
154
  syncLexicalStyleAttributes(container)
174
- const collectedStyles = collectDomTextStyles(container)
155
+ // Push color/background-color from non-span elements onto spans so the
156
+ // colorAwareSpanImport config binds it (the span importer can't see it
157
+ // otherwise). Must run after the sync above materializes data-lexical-*.
158
+ normalizeColoredContainers(container)
175
159
  const nodes = $generateNodesFromDOM(editor, container)
176
160
 
161
+ // Mark code blocks whose language came from an explicit source label as
162
+ // resolved BEFORE registerCodeHighlighting bakes the "javascript" default
163
+ // onto unlabeled ones. At this point a non-empty language can only be one
164
+ // the bridge set from a real fence/attribute, so the detection transform
165
+ // will honor it verbatim (incl. an explicit "javascript") and only
166
+ // re-detect the still-unlabeled blocks.
167
+ const markExplicitCodeLanguages = (list) => {
168
+ list.forEach((node) => {
169
+ if ($isCodeNode(node)) {
170
+ if (node.getLanguage()) markLanguageResolved(editor, node.getKey())
171
+ } else if ($isElementNode(node) && typeof node.getChildren === "function") {
172
+ markExplicitCodeLanguages(node.getChildren())
173
+ }
174
+ })
175
+ }
176
+ markExplicitCodeLanguages(nodes)
177
+
177
178
  // Filter out duplicate image nodes if any
178
179
  const uniqueNodes = []
179
180
  const seenImages = new Set()
@@ -192,24 +193,35 @@ function InitialContentPlugin({ html }) {
192
193
  })
193
194
 
194
195
  const appendedNodes = []
195
- uniqueNodes.forEach((node) => {
196
- if ($isTextNode(node)) {
197
- const paragraph = $createParagraphNode()
198
- paragraph.append(node)
199
- root.append(paragraph)
200
- appendedNodes.push(paragraph)
201
- return
196
+ // Text nodes and inline elements (links, etc.) cannot live directly under
197
+ // the root. Minimized HTML stores a single line without its <p> wrapper, so
198
+ // a line like "Hello <strong>World</strong>" re-imports as several
199
+ // top-level inline nodes — group consecutive ones back into one paragraph
200
+ // so the line is not split apart.
201
+ let pendingParagraph = null
202
+ const flushPending = () => {
203
+ if (pendingParagraph) {
204
+ root.append(pendingParagraph)
205
+ appendedNodes.push(pendingParagraph)
206
+ pendingParagraph = null
202
207
  }
203
-
204
- if ($isElementNode(node) && node.getType?.() === "paragraph") {
205
- root.append(node)
206
- appendedNodes.push(node)
208
+ }
209
+ uniqueNodes.forEach((node) => {
210
+ const isInlineLeaf =
211
+ $isTextNode(node) ||
212
+ $isLineBreakNode(node) ||
213
+ ($isElementNode(node) && node.isInline())
214
+ if (isInlineLeaf) {
215
+ if (!pendingParagraph) pendingParagraph = $createParagraphNode()
216
+ pendingParagraph.append(node)
207
217
  return
208
218
  }
209
219
 
220
+ flushPending()
210
221
  root.append(node)
211
222
  appendedNodes.push(node)
212
223
  })
224
+ flushPending()
213
225
 
214
226
  if (root.getChildrenSize() === 0) {
215
227
  const paragraph = $createParagraphNode()
@@ -217,12 +229,6 @@ function InitialContentPlugin({ html }) {
217
229
  appendedNodes.push(paragraph)
218
230
  }
219
231
 
220
- const textNodes = root.getAllTextNodes()
221
- textNodes.forEach((textNode, index) => {
222
- const style = collectedStyles[index]
223
- textNode.setStyle(style || "")
224
- })
225
-
226
232
  let lastChild = root.getLastChild()
227
233
  while (
228
234
  lastChild &&
@@ -236,12 +242,34 @@ function InitialContentPlugin({ html }) {
236
242
  if (root.getChildrenSize() === 0) {
237
243
  root.append($createParagraphNode())
238
244
  }
245
+
246
+ // A blank line the user typed is an empty paragraph, but the server renders
247
+ // it as a standalone <br> that re-imports as a paragraph holding a
248
+ // LineBreakNode — which Lexical draws as TWO lines, so blank lines grew by
249
+ // one on every reopen. Split those marker paragraphs back into empty
250
+ // paragraphs so reopened blank lines match freshly typed ones. Runs AFTER
251
+ // the trailing-empty cleanup above so an intentional trailing blank line
252
+ // (a marker paragraph the cleanup leaves alone) is preserved, not stripped.
253
+ splitBlankLineParagraphs(root)
254
+
255
+ // A trailing top-level image/video/attachment leaves no caret position
256
+ // after it, making the document uneditable. Guarantee an editable
257
+ // paragraph after it. (The RootNode transform below keeps this true while
258
+ // editing; this handles the initial load deterministically regardless of
259
+ // plugin effect ordering.)
260
+ ensureTrailingParagraph(root)
239
261
  })
240
- }, [collectDomTextStyles, editor, html])
262
+ }, [editor, html])
241
263
 
242
264
  return null
243
265
  }
244
266
 
267
+ function TrailingParagraphPlugin() {
268
+ const [editor] = useLexicalComposerContext()
269
+ useEffect(() => registerTrailingParagraph(editor), [editor])
270
+ return null
271
+ }
272
+
245
273
  function LinkAttributesPlugin() {
246
274
  const [editor] = useLexicalComposerContext()
247
275
 
@@ -268,7 +296,34 @@ function CodeHighlightingPlugin() {
268
296
  const [editor] = useLexicalComposerContext()
269
297
 
270
298
  useEffect(() => {
271
- return registerCodeHighlighting(editor)
299
+ const unregisterHighlight = registerCodeHighlighting(editor)
300
+
301
+ // registerCodeHighlighting bakes "javascript" onto any code block without a
302
+ // language (its tokenizer default), which then serializes into the canonical
303
+ // markdown as ```javascript — so Ruby/Python/etc. blocks get permanently
304
+ // mislabeled on the first edit. This transform re-detects the real language
305
+ // from the block's content whenever it's unconfirmed (missing or stuck on
306
+ // the javascript default) and corrects the node, so the editor shows — and
307
+ // saves — the right language. An explicit non-default language is left alone.
308
+ const unregisterDetect = editor.registerNodeTransform(CodeNode, (node) => {
309
+ // A language that came from an explicit source label on import is honored
310
+ // verbatim — including "javascript" — so auto-detection never overrides a
311
+ // deliberate choice. Only unlabeled/new blocks (baked to the javascript
312
+ // default) are re-detected from their content.
313
+ if (isLanguageResolved(editor, node.getKey())) return
314
+ const current = node.getLanguage()
315
+ const norm = normalizeFenceLang(current)
316
+ if (norm && norm !== "javascript") return
317
+ const detected = detectCodeLanguage(node.getTextContent(), current)
318
+ if (detected && detected !== "javascript" && detected !== current) {
319
+ node.setLanguage(detected)
320
+ }
321
+ })
322
+
323
+ return () => {
324
+ unregisterHighlight()
325
+ unregisterDetect()
326
+ }
272
327
  }, [editor])
273
328
 
274
329
  return null
@@ -495,6 +550,14 @@ function Toolbar() {
495
550
  [editor]
496
551
  )
497
552
 
553
+ const insertTable = useCallback(() => {
554
+ editor.dispatchCommand(INSERT_TABLE_COMMAND, {
555
+ columns: "3",
556
+ rows: "3",
557
+ includeHeaders: true
558
+ })
559
+ }, [editor])
560
+
498
561
  const toggleLink = useCallback(() => {
499
562
  let hasLink = false
500
563
  let selectionText = ""
@@ -733,6 +796,14 @@ function Toolbar() {
733
796
  title="Numbered list">
734
797
  1.
735
798
  </button>
799
+ <button
800
+ type="button"
801
+ className="lexical-toolbar-btn"
802
+ onClick={insertTable}
803
+ title="Insert table"
804
+ aria-label="Insert table">
805
+
806
+ </button>
736
807
  <span className="lexical-toolbar-separator" aria-hidden="true" />
737
808
  <button
738
809
  type="button"
@@ -893,6 +964,16 @@ function EditorInner({
893
964
  }) {
894
965
  const [editor] = useLexicalComposerContext()
895
966
 
967
+ // Anchor for the floating table plugins (hover "+" and the cell action menu).
968
+ // Portaling into the editor's own subtree (not document.body) ties the floating
969
+ // UI's lifetime to the editor DOM: when the editor is torn down — including
970
+ // Turbo/host teardown that removes the container without a React unmount — the
971
+ // chevron button is removed with it instead of being orphaned in document.body.
972
+ const [floatingAnchorElem, setFloatingAnchorElem] = useState(null)
973
+ const onAnchorRef = useCallback((el) => {
974
+ if (el !== null) setFloatingAnchorElem(el)
975
+ }, [])
976
+
896
977
  // File drop is handled by FileUploadPlugin's DROP_COMMAND handler.
897
978
  // We only need dragOver to allow the browser to accept file drops.
898
979
  const handleDragOver = useCallback((event) => {
@@ -904,7 +985,7 @@ function EditorInner({
904
985
  return (
905
986
  <div className="lexical-editor-shell">
906
987
  <Toolbar />
907
- <div className="lexical-editor-inner">
988
+ <div className="lexical-editor-inner" ref={onAnchorRef}>
908
989
  <RichTextPlugin
909
990
  contentEditable={
910
991
  <ContentEditable
@@ -922,12 +1003,18 @@ function EditorInner({
922
1003
  <HistoryPlugin />
923
1004
  <CodeHighlightingPlugin />
924
1005
  <ListPlugin />
1006
+ <TablePlugin hasCellMerge={false} hasCellBackgroundColor={false} />
1007
+ {floatingAnchorElem && (
1008
+ <TableHoverActionsPlugin anchorElem={floatingAnchorElem} />
1009
+ )}
1010
+ <ListTabIndentPlugin />
925
1011
  <LinkPlugin />
926
1012
  <AutoLinkPlugin matchers={URL_MATCHERS} />
927
1013
  <OnChangePlugin
928
1014
  onChange={(editorState, editorInstance) => {
929
1015
  if (!onChange) return
930
1016
  let serialized = ""
1017
+ let markdown = ""
931
1018
  editorState.read(() => {
932
1019
  const innerHtml = $generateHtmlFromNodes(editorInstance)
933
1020
  const parser = new DOMParser()
@@ -942,12 +1029,20 @@ function EditorInner({
942
1029
  anchor.setAttribute("target", "_blank")
943
1030
  anchor.setAttribute("rel", "noopener")
944
1031
  })
945
- serialized = doc.body.innerHTML
1032
+ // Strip Lexical's verbose markup (extra <div>, white-space spans,
1033
+ // duplicate format wrappers, single-line <p>) before persisting.
1034
+ serialized = minimizeContentHtml(doc.body.firstElementChild)
1035
+ // Canonical Markdown projection (color/bg -> normalized <span>).
1036
+ // normalizeMarkdownBlankLines keeps the standard `\n\n` paragraph
1037
+ // separation (Enter = real paragraph break) and only tidies blank-
1038
+ // line runs / the empty-state — no marker characters in the source.
1039
+ markdown = normalizeMarkdownBlankLines($convertToMarkdownString(MARKDOWN_TRANSFORMERS))
946
1040
  })
947
- // No Trix wrapper
948
- onChange(serialized)
1041
+ // html: client-side preview/fallback; markdown: canonical storage.
1042
+ onChange({ html: serialized, markdown })
949
1043
  }}
950
1044
  />
1045
+ <TrailingParagraphPlugin />
951
1046
  <InitialContentPlugin html={initialHtml} />
952
1047
  <LinkAttributesPlugin />
953
1048
  <ReadyPlugin onReady={onReady} />
@@ -1019,12 +1114,17 @@ export default function InlineLexicalEditor({
1019
1114
  LinkNode,
1020
1115
  AutoLinkNode,
1021
1116
  ImageNode,
1022
- AttachmentNode
1117
+ AttachmentNode,
1118
+ VideoNode,
1119
+ TableNode,
1120
+ TableRowNode,
1121
+ TableCellNode
1023
1122
  ],
1024
1123
  onError(error) {
1025
1124
  throw error
1026
1125
  },
1027
- theme
1126
+ theme,
1127
+ html: lexicalHtmlConfig
1028
1128
  }),
1029
1129
  []
1030
1130
  )
@@ -1,7 +1,9 @@
1
1
  import { LitElement, html, svg, nothing } from "lit";
2
- import DOMPurify from "dompurify";
3
2
  import { unsafeHTML } from "lit/directives/unsafe-html.js";
4
3
  import { parseEmojis } from "../utils/emoji_parser";
4
+ import { highlightCodeBlocks } from "../lib/utils/markdown";
5
+ import { addCreativeTableDownloadButtons } from "../lib/utils/table_download";
6
+ import { sanitizeDescriptionHtml } from "../lib/utils/sanitize_description";
5
7
  import csrfFetch from "../lib/api/csrf_fetch";
6
8
 
7
9
  const BULLET_STARTING_LEVEL = 3;
@@ -78,6 +80,19 @@ class CreativeTreeRow extends LitElement {
78
80
  updated(changedProperties) {
79
81
  this._attachHandlers();
80
82
 
83
+ // Re-tokenize the server-rendered description code blocks with hljs so they
84
+ // match the editor's palette and follow light/dark theme. Idempotent: only
85
+ // unmarked blocks are processed, and lit re-renders description DOM (dropping
86
+ // the marker) only when descriptionHtml actually changes.
87
+ highlightCodeBlocks(this);
88
+
89
+ // Attach CSV/Excel download toolbars to markdown tables in the description
90
+ // display areas so creative tables match the chat-message table UI.
91
+ // Scoped to .creative-content/.creative-title-content (not the whole row)
92
+ // so the inline editor's own tables are never wrapped. Idempotent, safe on
93
+ // every Lit re-render.
94
+ addCreativeTableDownloadButtons(this);
95
+
81
96
  if (changedProperties.has('loadingChildren')) {
82
97
  if (this.loadingChildren) {
83
98
  this._startAnimation();
@@ -99,8 +114,10 @@ class CreativeTreeRow extends LitElement {
99
114
 
100
115
  set descriptionHtml(value) {
101
116
  const oldValue = this._descriptionHtml;
102
- // Always sanitize when setting new HTML
103
- const sanitized = DOMPurify.sanitize(value ?? "");
117
+ // Always sanitize when setting new HTML. Uses the shared sanitizer so the
118
+ // server-generated YouTube preview iframe survives (default DOMPurify config
119
+ // strips all iframes, which is what broke the YouTube link preview).
120
+ const sanitized = sanitizeDescriptionHtml(value);
104
121
  this._descriptionHtml = sanitized;
105
122
  this.dataset.descriptionHtml = sanitized;
106
123
  this.requestUpdate("descriptionHtml", oldValue);
@@ -3,6 +3,7 @@ import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext
3
3
  import { $getRoot } from "lexical"
4
4
  import { $isImageNode } from "../../lib/lexical/image_node"
5
5
  import { $isAttachmentNode } from "../../lib/lexical/attachment_node"
6
+ import { $isVideoNode } from "../../lib/lexical/video_node"
6
7
 
7
8
  function extractSignedIdFromUrl(url) {
8
9
  if (!url) return null
@@ -27,6 +28,8 @@ function getAllAttachmentUrls(editor) {
27
28
  function traverse(node) {
28
29
  if ($isImageNode(node)) {
29
30
  urls.add(node.getSrc())
31
+ } else if ($isVideoNode(node)) {
32
+ urls.add(node.getSrc())
30
33
  } else if ($isAttachmentNode(node)) {
31
34
  urls.add(node.getSrc())
32
35
  }
@@ -15,6 +15,7 @@ import {
15
15
 
16
16
  import { $createImageNode } from "../../lib/lexical/image_node"
17
17
  import { $createAttachmentNode } from "../../lib/lexical/attachment_node"
18
+ import { $createVideoNode } from "../../lib/lexical/video_node"
18
19
 
19
20
  export const INSERT_IMAGE_COMMAND = createCommand("INSERT_IMAGE_COMMAND")
20
21
  export const INSERT_FILE_COMMAND = createCommand("INSERT_FILE_COMMAND")
@@ -25,6 +26,12 @@ function isImageFile(file) {
25
26
  return /\.(bmp|gif|jpe?g|png|svg|webp)$/i.test(file.name || "")
26
27
  }
27
28
 
29
+ function isVideoFile(file) {
30
+ if (!file) return false
31
+ if (file.type) return /^video\//i.test(file.type)
32
+ return /\.(mp4|webm|mov|m4v)$/i.test(file.name || "")
33
+ }
34
+
28
35
  export default function FileUploadPlugin({
29
36
  onUploadStateChange,
30
37
  directUploadUrl,
@@ -78,6 +85,11 @@ export default function FileUploadPlugin({
78
85
  altText: attributes.filename,
79
86
  maxWidth: 800 // Default max width
80
87
  })
88
+ } else if (isVideoFile(file)) {
89
+ node = $createVideoNode({
90
+ src: url,
91
+ filename: attributes.filename
92
+ })
81
93
  } else {
82
94
  node = $createAttachmentNode({
83
95
  src: url,
@@ -0,0 +1,16 @@
1
+ import { useEffect } from "react"
2
+ import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
3
+ import { registerListTabIndentation } from "../../lib/lexical/list_tab_indent"
4
+
5
+ /**
6
+ * Enables Tab / Shift+Tab nesting for list items in the inline editor.
7
+ * The actual command handling lives in registerListTabIndentation so it can be
8
+ * unit-tested against a headless editor.
9
+ */
10
+ export default function ListTabIndentPlugin() {
11
+ const [editor] = useLexicalComposerContext()
12
+
13
+ useEffect(() => registerListTabIndentation(editor), [editor])
14
+
15
+ return null
16
+ }