collavre 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -0
  3. data/app/assets/stylesheets/collavre/actiontext.css +251 -90
  4. data/app/assets/stylesheets/collavre/code_highlight.css +7 -201
  5. data/app/assets/stylesheets/collavre/comments_popup.css +118 -61
  6. data/app/assets/stylesheets/collavre/creatives.css +11 -2
  7. data/app/assets/stylesheets/collavre/modal_dialog.css +32 -0
  8. data/app/assets/stylesheets/collavre/tables.css +91 -0
  9. data/app/channels/collavre/inbox_badge_channel.rb +30 -0
  10. data/app/controllers/collavre/api/v1/mobile/agent_events_controller.rb +224 -0
  11. data/app/controllers/collavre/api/v1/mobile/base_controller.rb +95 -0
  12. data/app/controllers/collavre/api/v1/mobile/devices_controller.rb +31 -0
  13. data/app/controllers/collavre/api/v1/mobile/voice_commands_controller.rb +25 -0
  14. data/app/controllers/collavre/creatives_controller.rb +16 -5
  15. data/app/controllers/collavre/tasks_controller.rb +13 -4
  16. data/app/controllers/collavre/topics_controller.rb +49 -1
  17. data/app/controllers/collavre/typo_corrections_controller.rb +39 -0
  18. data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +16 -1
  19. data/app/controllers/concerns/collavre/users_controller/registration.rb +41 -1
  20. data/app/helpers/collavre/application_helper.rb +1 -0
  21. data/app/javascript/collavre.js +2 -0
  22. data/app/javascript/components/ImageResizer.jsx +9 -3
  23. data/app/javascript/components/InlineLexicalEditor.jsx +155 -38
  24. data/app/javascript/components/creative_tree_row.js +20 -3
  25. data/app/javascript/components/plugins/list_tab_indent_plugin.jsx +16 -0
  26. data/app/javascript/components/plugins/table_hover_actions_plugin.jsx +405 -0
  27. data/app/javascript/controllers/__tests__/inbox_badge_controller.test.js +73 -0
  28. data/app/javascript/controllers/comment_controller.js +5 -4
  29. data/app/javascript/controllers/comment_version_controller.js +2 -1
  30. data/app/javascript/controllers/comments/__tests__/form_controller_double_submit.test.js +159 -0
  31. data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +3 -2
  32. data/app/javascript/controllers/comments/__tests__/topics_controller_delete.test.js +94 -0
  33. data/app/javascript/controllers/comments/form_controller.js +21 -5
  34. data/app/javascript/controllers/comments/list_controller.js +18 -17
  35. data/app/javascript/controllers/comments/presence_controller.js +2 -1
  36. data/app/javascript/controllers/comments/topics_controller.js +14 -8
  37. data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +150 -0
  38. data/app/javascript/controllers/creatives/import_controller.js +2 -1
  39. data/app/javascript/controllers/creatives/select_mode_controller.js +2 -1
  40. data/app/javascript/controllers/creatives/tree_controller.js +142 -1
  41. data/app/javascript/controllers/image_lightbox_controller.js +2 -1
  42. data/app/javascript/controllers/inbox_badge_controller.js +33 -0
  43. data/app/javascript/controllers/index.js +4 -1
  44. data/app/javascript/controllers/share_modal_controller.js +4 -3
  45. data/app/javascript/controllers/topic_search_controller.js +2 -1
  46. data/app/javascript/creatives/drag_drop/event_handlers.js +14 -5
  47. data/app/javascript/creatives/topic_move_members_popup.js +156 -0
  48. data/app/javascript/creatives/tree_renderer.js +11 -0
  49. data/app/javascript/lib/__tests__/turbo_confirm.test.js +81 -0
  50. data/app/javascript/lib/__tests__/typo_correction.test.js +192 -0
  51. data/app/javascript/lib/api/__tests__/api_error.test.js +96 -0
  52. data/app/javascript/lib/api/__tests__/queue_manager.test.js +88 -1
  53. data/app/javascript/lib/api/api_error.js +108 -0
  54. data/app/javascript/lib/api/queue_manager.js +38 -4
  55. data/app/javascript/lib/common_popup.js +18 -5
  56. data/app/javascript/lib/editor/__tests__/code_edit_view_token_parity.test.js +121 -0
  57. data/app/javascript/lib/editor/__tests__/code_language_roundtrip.test.js +152 -0
  58. data/app/javascript/lib/editor/__tests__/code_languages.test.js +93 -0
  59. data/app/javascript/lib/editor/code_languages.js +173 -0
  60. data/app/javascript/lib/editor/code_token_theme.js +41 -0
  61. data/app/javascript/lib/lexical/__tests__/image_focus.test.js +139 -0
  62. data/app/javascript/lib/lexical/__tests__/list_tab_indent.test.js +633 -0
  63. data/app/javascript/lib/lexical/__tests__/markdown_serialize.test.js +627 -0
  64. data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +20 -1
  65. data/app/javascript/lib/lexical/__tests__/selection_boundary.test.js +88 -0
  66. data/app/javascript/lib/lexical/__tests__/table_transformer.test.js +163 -0
  67. data/app/javascript/lib/lexical/__tests__/trailing_paragraph.test.js +104 -0
  68. data/app/javascript/lib/lexical/list_tab_indent.js +210 -0
  69. data/app/javascript/lib/lexical/markdown_serialize.js +320 -0
  70. data/app/javascript/lib/lexical/selection_boundary.js +58 -0
  71. data/app/javascript/lib/lexical/table_transformer.js +182 -0
  72. data/app/javascript/lib/lexical/trailing_paragraph.js +29 -0
  73. data/app/javascript/lib/turbo_confirm.js +46 -0
  74. data/app/javascript/lib/typo_correction.js +146 -0
  75. data/app/javascript/lib/utils/__tests__/confirm_dialog.test.js +88 -0
  76. data/app/javascript/lib/utils/__tests__/dialog.test.js +92 -0
  77. data/app/javascript/lib/utils/__tests__/markdown.test.js +153 -0
  78. data/app/javascript/lib/utils/__tests__/sanitize_description.test.js +68 -0
  79. data/app/javascript/lib/utils/__tests__/table_download.test.js +93 -0
  80. data/app/javascript/lib/utils/confirm_dialog.js +10 -0
  81. data/app/javascript/lib/utils/dialog.js +300 -0
  82. data/app/javascript/lib/utils/markdown.js +154 -67
  83. data/app/javascript/lib/utils/sanitize_description.js +31 -0
  84. data/app/javascript/lib/utils/table_download.js +15 -0
  85. data/app/javascript/modules/__tests__/typo_corrector.test.js +365 -0
  86. data/app/javascript/modules/creative_row_editor.js +110 -70
  87. data/app/javascript/modules/export_to_markdown.js +2 -1
  88. data/app/javascript/modules/lexical_inline_editor.jsx +2 -1
  89. data/app/javascript/modules/slide_view.js +11 -2
  90. data/app/javascript/modules/typo_corrector.js +534 -0
  91. data/app/jobs/collavre/ai_agent_job.rb +7 -4
  92. data/app/jobs/collavre/compress_job.rb +6 -2
  93. data/app/models/collavre/comment/broadcastable.rb +46 -7
  94. data/app/models/collavre/comment/notifiable.rb +14 -4
  95. data/app/models/collavre/comment.rb +79 -31
  96. data/app/models/collavre/creative/describable.rb +89 -10
  97. data/app/models/collavre/task.rb +15 -0
  98. data/app/models/collavre/user.rb +57 -1
  99. data/app/services/collavre/ai_client.rb +28 -10
  100. data/app/services/collavre/auto_theme_generator.rb +1 -1
  101. data/app/services/collavre/creatives/index_query.rb +85 -16
  102. data/app/services/collavre/creatives/tree_builder.rb +2 -1
  103. data/app/services/collavre/gemini_parent_recommender.rb +1 -1
  104. data/app/services/collavre/inbox_reply_service.rb +5 -0
  105. data/app/services/collavre/markdown_converter.rb +13 -3
  106. data/app/services/collavre/mobile/event_summarizer.rb +40 -0
  107. data/app/services/collavre/orchestration/agent_orchestrator.rb +33 -7
  108. data/app/services/collavre/orchestration/arbiter.rb +16 -0
  109. data/app/services/collavre/orchestration/matcher.rb +79 -4
  110. data/app/services/collavre/orchestration/policy_resolver.rb +4 -3
  111. data/app/services/collavre/orchestration/stuck_detector.rb +141 -34
  112. data/app/services/collavre/tools/creative_batch_service.rb +3 -2
  113. data/app/services/collavre/tools/creative_create_service.rb +8 -8
  114. data/app/services/collavre/tools/creative_update_service.rb +23 -8
  115. data/app/services/collavre/typo_corrector.rb +188 -0
  116. data/app/views/collavre/comments/_comment.html.erb +5 -0
  117. data/app/views/collavre/comments/_comments_popup.html.erb +14 -1
  118. data/app/views/collavre/creatives/_inline_edit_form.html.erb +1 -0
  119. data/app/views/collavre/creatives/_topic_move_members_modal.html.erb +42 -0
  120. data/app/views/collavre/creatives/index.html.erb +14 -1
  121. data/app/views/collavre/creatives/slide_view.html.erb +1 -1
  122. data/app/views/collavre/users/show.html.erb +3 -0
  123. data/app/views/collavre/users/typo_correction.html.erb +50 -0
  124. data/app/views/layouts/collavre/slide.html.erb +1 -0
  125. data/config/locales/comments.en.yml +15 -0
  126. data/config/locales/comments.ko.yml +15 -0
  127. data/config/locales/integrations.en.yml +1 -1
  128. data/config/locales/integrations.ko.yml +1 -1
  129. data/config/locales/mobile.en.yml +16 -0
  130. data/config/locales/mobile.ko.yml +16 -0
  131. data/config/locales/orchestration.en.yml +1 -0
  132. data/config/locales/orchestration.ko.yml +1 -0
  133. data/config/locales/users.en.yml +15 -0
  134. data/config/locales/users.ko.yml +15 -0
  135. data/config/routes.rb +13 -0
  136. data/db/migrate/20260612000000_add_topic_concurrency_defer_to_comments.rb +38 -0
  137. data/db/migrate/20260617090000_add_typo_correction_settings_to_users.rb +18 -0
  138. data/db/seeds.rb +51 -0
  139. data/lib/collavre/version.rb +1 -1
  140. data/lib/generators/collavre/install/install_generator.rb +1 -0
  141. metadata +55 -2
  142. data/app/services/collavre/tools/description_normalizable.rb +0 -16
@@ -21,6 +21,9 @@ 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,
@@ -51,10 +54,20 @@ import { AttachmentNode } from "../lib/lexical/attachment_node"
51
54
  import { VideoNode } from "../lib/lexical/video_node"
52
55
  import AttachmentCleanupPlugin from "./plugins/attachment_cleanup_plugin"
53
56
  import MarkdownShortcutsPlugin from "./plugins/markdown_shortcuts_plugin"
57
+ import ListTabIndentPlugin from "./plugins/list_tab_indent_plugin"
54
58
  import { syncLexicalStyleAttributes } from "../lib/lexical/style_attributes"
55
59
  import { lexicalHtmlConfig, normalizeColoredContainers } from "../lib/lexical/color_import"
56
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"
57
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"
58
71
 
59
72
  const URL_MATCHERS = [
60
73
  createLinkMatcherWithRegExp(/https?:\/\/[^\s<]+/gi, (text) => text)
@@ -71,40 +84,14 @@ const theme = {
71
84
  list: {
72
85
  ul: "lexical-list-ul",
73
86
  ol: "lexical-list-ol",
74
- 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" }
75
92
  },
76
93
  code: "lexical-code-block",
77
- codeHighlight: {
78
- atrule: "lexical-token-atrule",
79
- attr: "lexical-token-attr",
80
- boolean: "lexical-token-boolean",
81
- builtin: "lexical-token-builtin",
82
- cdata: "lexical-token-cdata",
83
- char: "lexical-token-char",
84
- class: "lexical-token-class",
85
- comment: "lexical-token-comment",
86
- constant: "lexical-token-constant",
87
- deleted: "lexical-token-deleted",
88
- doctype: "lexical-token-doctype",
89
- entity: "lexical-token-entity",
90
- function: "lexical-token-function",
91
- important: "lexical-token-important",
92
- inserted: "lexical-token-inserted",
93
- keyword: "lexical-token-keyword",
94
- namespace: "lexical-token-namespace",
95
- number: "lexical-token-number",
96
- operator: "lexical-token-operator",
97
- prolog: "lexical-token-prolog",
98
- property: "lexical-token-property",
99
- punctuation: "lexical-token-punctuation",
100
- regex: "lexical-token-regex",
101
- selector: "lexical-token-selector",
102
- string: "lexical-token-string",
103
- symbol: "lexical-token-symbol",
104
- tag: "lexical-token-tag",
105
- url: "lexical-token-url",
106
- variable: "lexical-token-variable"
107
- },
94
+ codeHighlight: CODE_TOKEN_THEME,
108
95
  link: "lexical-link",
109
96
  text: {
110
97
  bold: "lexical-text-bold",
@@ -112,7 +99,18 @@ const theme = {
112
99
  underline: "lexical-text-underline",
113
100
  strikethrough: "lexical-text-strike",
114
101
  code: "lexical-text-code"
115
- }
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"
116
114
  }
117
115
 
118
116
  function Placeholder({ text }) {
@@ -129,6 +127,9 @@ function InitialContentPlugin({ html }) {
129
127
  lastApplied.current = html
130
128
  editor.update(() => {
131
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)
132
133
  // Explicitly remove all children to ensure it's empty
133
134
  root.getChildren().forEach((child) => child.remove())
134
135
 
@@ -137,6 +138,15 @@ function InitialContentPlugin({ html }) {
137
138
  // No more .trix-content wrapper
138
139
  const container = doc.body
139
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
+
140
150
  // Color / background-color are bound to text nodes during import by the
141
151
  // colorAwareSpanImport html config (see lib/lexical/color_import). We no
142
152
  // longer re-apply styles positionally after import, which used to drift
@@ -148,6 +158,23 @@ function InitialContentPlugin({ html }) {
148
158
  normalizeColoredContainers(container)
149
159
  const nodes = $generateNodesFromDOM(editor, container)
150
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
+
151
178
  // Filter out duplicate image nodes if any
152
179
  const uniqueNodes = []
153
180
  const seenImages = new Set()
@@ -215,12 +242,34 @@ function InitialContentPlugin({ html }) {
215
242
  if (root.getChildrenSize() === 0) {
216
243
  root.append($createParagraphNode())
217
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)
218
261
  })
219
262
  }, [editor, html])
220
263
 
221
264
  return null
222
265
  }
223
266
 
267
+ function TrailingParagraphPlugin() {
268
+ const [editor] = useLexicalComposerContext()
269
+ useEffect(() => registerTrailingParagraph(editor), [editor])
270
+ return null
271
+ }
272
+
224
273
  function LinkAttributesPlugin() {
225
274
  const [editor] = useLexicalComposerContext()
226
275
 
@@ -247,7 +296,34 @@ function CodeHighlightingPlugin() {
247
296
  const [editor] = useLexicalComposerContext()
248
297
 
249
298
  useEffect(() => {
250
- 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
+ }
251
327
  }, [editor])
252
328
 
253
329
  return null
@@ -474,6 +550,14 @@ function Toolbar() {
474
550
  [editor]
475
551
  )
476
552
 
553
+ const insertTable = useCallback(() => {
554
+ editor.dispatchCommand(INSERT_TABLE_COMMAND, {
555
+ columns: "3",
556
+ rows: "3",
557
+ includeHeaders: true
558
+ })
559
+ }, [editor])
560
+
477
561
  const toggleLink = useCallback(() => {
478
562
  let hasLink = false
479
563
  let selectionText = ""
@@ -712,6 +796,14 @@ function Toolbar() {
712
796
  title="Numbered list">
713
797
  1.
714
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>
715
807
  <span className="lexical-toolbar-separator" aria-hidden="true" />
716
808
  <button
717
809
  type="button"
@@ -872,6 +964,16 @@ function EditorInner({
872
964
  }) {
873
965
  const [editor] = useLexicalComposerContext()
874
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
+
875
977
  // File drop is handled by FileUploadPlugin's DROP_COMMAND handler.
876
978
  // We only need dragOver to allow the browser to accept file drops.
877
979
  const handleDragOver = useCallback((event) => {
@@ -883,7 +985,7 @@ function EditorInner({
883
985
  return (
884
986
  <div className="lexical-editor-shell">
885
987
  <Toolbar />
886
- <div className="lexical-editor-inner">
988
+ <div className="lexical-editor-inner" ref={onAnchorRef}>
887
989
  <RichTextPlugin
888
990
  contentEditable={
889
991
  <ContentEditable
@@ -901,12 +1003,18 @@ function EditorInner({
901
1003
  <HistoryPlugin />
902
1004
  <CodeHighlightingPlugin />
903
1005
  <ListPlugin />
1006
+ <TablePlugin hasCellMerge={false} hasCellBackgroundColor={false} />
1007
+ {floatingAnchorElem && (
1008
+ <TableHoverActionsPlugin anchorElem={floatingAnchorElem} />
1009
+ )}
1010
+ <ListTabIndentPlugin />
904
1011
  <LinkPlugin />
905
1012
  <AutoLinkPlugin matchers={URL_MATCHERS} />
906
1013
  <OnChangePlugin
907
1014
  onChange={(editorState, editorInstance) => {
908
1015
  if (!onChange) return
909
1016
  let serialized = ""
1017
+ let markdown = ""
910
1018
  editorState.read(() => {
911
1019
  const innerHtml = $generateHtmlFromNodes(editorInstance)
912
1020
  const parser = new DOMParser()
@@ -924,11 +1032,17 @@ function EditorInner({
924
1032
  // Strip Lexical's verbose markup (extra <div>, white-space spans,
925
1033
  // duplicate format wrappers, single-line <p>) before persisting.
926
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))
927
1040
  })
928
- // No Trix wrapper
929
- onChange(serialized)
1041
+ // html: client-side preview/fallback; markdown: canonical storage.
1042
+ onChange({ html: serialized, markdown })
930
1043
  }}
931
1044
  />
1045
+ <TrailingParagraphPlugin />
932
1046
  <InitialContentPlugin html={initialHtml} />
933
1047
  <LinkAttributesPlugin />
934
1048
  <ReadyPlugin onReady={onReady} />
@@ -1001,7 +1115,10 @@ export default function InlineLexicalEditor({
1001
1115
  AutoLinkNode,
1002
1116
  ImageNode,
1003
1117
  AttachmentNode,
1004
- VideoNode
1118
+ VideoNode,
1119
+ TableNode,
1120
+ TableRowNode,
1121
+ TableCellNode
1005
1122
  ],
1006
1123
  onError(error) {
1007
1124
  throw error
@@ -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);
@@ -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
+ }