collavre 0.20.3 → 0.22.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 (163) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/collavre/actiontext.css +92 -2
  3. data/app/assets/stylesheets/collavre/code_highlight.css +144 -26
  4. data/app/assets/stylesheets/collavre/comments_popup.css +133 -2
  5. data/app/assets/stylesheets/collavre/landing.css +507 -0
  6. data/app/assets/stylesheets/collavre/popup.css +148 -0
  7. data/app/channels/collavre/agent_channel.rb +205 -0
  8. data/app/channels/collavre/comments_presence_channel.rb +7 -0
  9. data/app/controllers/collavre/admin/integrations_controller.rb +93 -0
  10. data/app/controllers/collavre/admin/settings_controller.rb +22 -17
  11. data/app/controllers/collavre/api/v1/agents_controller.rb +777 -0
  12. data/app/controllers/collavre/api/v1/base_controller.rb +46 -0
  13. data/app/controllers/collavre/application_controller.rb +27 -0
  14. data/app/controllers/collavre/attachments_controller.rb +30 -2
  15. data/app/controllers/collavre/channels_controller.rb +23 -0
  16. data/app/controllers/collavre/comments_controller.rb +1 -1
  17. data/app/controllers/collavre/creatives/attachments_controller.rb +79 -0
  18. data/app/controllers/collavre/creatives_controller.rb +141 -7
  19. data/app/controllers/collavre/landing_controller.rb +8 -0
  20. data/app/controllers/collavre/public_assets_controller.rb +24 -0
  21. data/app/controllers/collavre/tasks_controller.rb +12 -4
  22. data/app/controllers/collavre/topics_controller.rb +36 -30
  23. data/app/controllers/concerns/collavre/comments/approval_actions.rb +57 -14
  24. data/app/helpers/collavre/comments_helper.rb +7 -0
  25. data/app/helpers/collavre/public_assets_helper.rb +14 -0
  26. data/app/javascript/components/InlineLexicalEditor.jsx +42 -59
  27. data/app/javascript/components/plugins/attachment_cleanup_plugin.jsx +3 -0
  28. data/app/javascript/components/plugins/image_upload_plugin.jsx +12 -0
  29. data/app/javascript/controllers/__tests__/link_creative_controller.test.js +447 -0
  30. data/app/javascript/controllers/comment_controller.js +15 -1
  31. data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +108 -0
  32. data/app/javascript/controllers/comments/form_controller.js +4 -0
  33. data/app/javascript/controllers/comments/list_controller.js +27 -9
  34. data/app/javascript/controllers/comments/popup_controller.js +9 -0
  35. data/app/javascript/controllers/comments/presence_controller.js +137 -4
  36. data/app/javascript/controllers/comments/topics_controller.js +15 -0
  37. data/app/javascript/controllers/creatives/__tests__/sync_controller.test.js +89 -0
  38. data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +120 -0
  39. data/app/javascript/controllers/creatives/sync_controller.js +30 -9
  40. data/app/javascript/controllers/creatives/tree_controller.js +23 -0
  41. data/app/javascript/controllers/index.js +4 -1
  42. data/app/javascript/controllers/landing_video_controller.js +53 -0
  43. data/app/javascript/controllers/link_creative_controller.js +451 -29
  44. data/app/javascript/creatives/tree_renderer.js +6 -0
  45. data/app/javascript/lib/api/__tests__/queue_manager.test.js +27 -0
  46. data/app/javascript/lib/api/creatives.js +13 -0
  47. data/app/javascript/lib/api/queue_manager.js +17 -5
  48. data/app/javascript/lib/lexical/__tests__/color_import.test.js +318 -0
  49. data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +259 -0
  50. data/app/javascript/lib/lexical/color_import.js +186 -0
  51. data/app/javascript/lib/lexical/minimize_html.js +182 -0
  52. data/app/javascript/lib/lexical/video_node.jsx +96 -0
  53. data/app/javascript/modules/__tests__/command_args_form.test.js +103 -0
  54. data/app/javascript/modules/__tests__/html_content_empty.test.js +41 -0
  55. data/app/javascript/modules/__tests__/markdown_source_reconcile.test.js +70 -0
  56. data/app/javascript/modules/command_args_form.js +22 -4
  57. data/app/javascript/modules/command_menu.js +27 -0
  58. data/app/javascript/modules/creative_row_editor.js +227 -17
  59. data/app/javascript/modules/html_content_empty.js +12 -0
  60. data/app/javascript/modules/markdown_source_reconcile.js +53 -0
  61. data/app/jobs/collavre/ai_agent_job.rb +89 -3
  62. data/app/jobs/collavre/cancel_offline_delegated_tasks_job.rb +134 -0
  63. data/app/jobs/collavre/claude_channel_presence_job.rb +91 -0
  64. data/app/jobs/collavre/drop_trigger_job.rb +37 -8
  65. data/app/mailers/collavre/application_mailer.rb +1 -1
  66. data/app/models/collavre/agent_subscription.rb +52 -0
  67. data/app/models/collavre/channel/injected_message.rb +5 -0
  68. data/app/models/collavre/channel.rb +87 -0
  69. data/app/models/collavre/comment/claude_channel_permission.rb +145 -0
  70. data/app/models/collavre/comment.rb +70 -5
  71. data/app/models/collavre/creative/describable.rb +202 -3
  72. data/app/models/collavre/creative.rb +2 -0
  73. data/app/models/collavre/creative_share.rb +1 -0
  74. data/app/models/collavre/integration_setting.rb +35 -0
  75. data/app/models/collavre/preview_channel.rb +93 -0
  76. data/app/models/collavre/system_setting.rb +13 -2
  77. data/app/models/collavre/task.rb +34 -5
  78. data/app/models/collavre/topic.rb +8 -25
  79. data/app/models/collavre/user.rb +4 -0
  80. data/app/models/concerns/collavre/ai_agent_resolvable.rb +12 -3
  81. data/app/services/collavre/agent_session_abort.rb +28 -0
  82. data/app/services/collavre/ai_agent/claude_channel_adapter.rb +79 -0
  83. data/app/services/collavre/ai_agent/response_finalizer.rb +4 -2
  84. data/app/services/collavre/ai_agent_service.rb +68 -49
  85. data/app/services/collavre/ai_client.rb +3 -3
  86. data/app/services/collavre/attachment_backfill.rb +26 -0
  87. data/app/services/collavre/channel_attacher.rb +58 -0
  88. data/app/services/collavre/comments/mcp_command.rb +31 -1
  89. data/app/services/collavre/creatives/breadcrumb_resolver.rb +91 -0
  90. data/app/services/collavre/creatives/filter_pipeline.rb +26 -42
  91. data/app/services/collavre/creatives/filters/search_filter.rb +12 -2
  92. data/app/services/collavre/creatives/index_query.rb +110 -8
  93. data/app/services/collavre/creatives/permission_filter.rb +50 -0
  94. data/app/services/collavre/creatives/reveal_path_resolver.rb +118 -0
  95. data/app/services/collavre/creatives/tree_builder.rb +7 -3
  96. data/app/services/collavre/crons/recurring_task_arguments.rb +28 -0
  97. data/app/services/collavre/google_calendar_service.rb +4 -2
  98. data/app/services/collavre/markdown_converter.rb +130 -15
  99. data/app/services/collavre/markdown_importer.rb +7 -2
  100. data/app/services/collavre/orchestration/policy_resolver.rb +11 -1
  101. data/app/services/collavre/orchestration/stuck_detector.rb +22 -2
  102. data/app/services/collavre/tools/creative_attach_files_service.rb +62 -0
  103. data/app/services/collavre/tools/creative_list_attachments_service.rb +42 -0
  104. data/app/services/collavre/tools/creative_remove_attachment_service.rb +37 -0
  105. data/app/services/collavre/tools/cron_list_service.rb +1 -14
  106. data/app/services/collavre/tools/permission_denied_error.rb +9 -0
  107. data/app/services/collavre/tools/preview_attach_service.rb +128 -0
  108. data/app/services/collavre/tools/preview_detach_service.rb +61 -0
  109. data/app/services/collavre/tools/topic_authorizer.rb +24 -0
  110. data/app/services/collavre/topic_branch_service.rb +34 -26
  111. data/app/services/collavre/topics/orphaned_cron_notifier.rb +68 -0
  112. data/app/views/admin/shared/_tabs.html.erb +1 -0
  113. data/app/views/collavre/admin/integrations/_category.html.erb +22 -0
  114. data/app/views/collavre/admin/integrations/_setting_row.html.erb +70 -0
  115. data/app/views/collavre/admin/integrations/index.html.erb +42 -0
  116. data/app/views/collavre/admin/settings/_system_tab.html.erb +8 -0
  117. data/app/views/collavre/comments/_channel_chips.html.erb +33 -0
  118. data/app/views/collavre/comments/_comment.html.erb +16 -2
  119. data/app/views/collavre/comments/_comments_popup.html.erb +4 -1
  120. data/app/views/collavre/creatives/_inline_edit_form.html.erb +19 -0
  121. data/app/views/collavre/creatives/index.html.erb +10 -2
  122. data/app/views/collavre/landing/show.html.erb +130 -0
  123. data/app/views/collavre/shared/_link_creative_modal.html.erb +6 -2
  124. data/app/views/layouts/collavre/landing.html.erb +33 -0
  125. data/config/locales/admin.en.yml +4 -2
  126. data/config/locales/admin.ko.yml +4 -2
  127. data/config/locales/channels.en.yml +13 -0
  128. data/config/locales/channels.ko.yml +13 -0
  129. data/config/locales/claude_channel.en.yml +16 -0
  130. data/config/locales/claude_channel.ko.yml +16 -0
  131. data/config/locales/comments.en.yml +5 -0
  132. data/config/locales/comments.ko.yml +5 -0
  133. data/config/locales/creatives.en.yml +11 -0
  134. data/config/locales/creatives.ko.yml +10 -0
  135. data/config/locales/integrations.en.yml +55 -0
  136. data/config/locales/integrations.ko.yml +55 -0
  137. data/config/locales/landing.en.yml +51 -0
  138. data/config/locales/landing.ko.yml +51 -0
  139. data/config/routes.rb +30 -0
  140. data/db/migrate/20260526000000_create_channels.rb +42 -0
  141. data/db/migrate/20260527000000_add_dismissed_at_to_channels.rb +6 -0
  142. data/db/migrate/20260527000100_backfill_dismissed_at_for_legacy_detached_channels.rb +28 -0
  143. data/db/migrate/20260528000000_add_preview_channel_unique_index.rb +31 -0
  144. data/db/migrate/20260529000000_add_primary_agent_id_to_topics.rb +40 -0
  145. data/db/migrate/20260529100000_create_integration_settings.rb +15 -0
  146. data/db/migrate/20260609000000_drop_action_text_rich_texts.rb +20 -0
  147. data/db/migrate/20260609005000_add_session_id_to_topics.rb +16 -0
  148. data/db/migrate/20260609010000_create_agent_subscriptions.rb +19 -0
  149. data/db/migrate/20260609020000_add_last_seen_at_to_agent_subscriptions.rb +23 -0
  150. data/db/migrate/20260609030000_add_session_id_to_agent_subscriptions.rb +17 -0
  151. data/db/migrate/20260609190659_backfill_creative_files_into_description.rb +24 -0
  152. data/db/seeds.rb +19 -0
  153. data/lib/collavre/aws_credentials.rb +75 -0
  154. data/lib/collavre/engine.rb +50 -0
  155. data/lib/collavre/integration_settings/key_definition.rb +35 -0
  156. data/lib/collavre/integration_settings/registry.rb +60 -0
  157. data/lib/collavre/integration_settings/resolver.rb +71 -0
  158. data/lib/collavre/integration_settings.rb +46 -0
  159. data/lib/collavre/ses_settings_interceptor.rb +72 -0
  160. data/lib/collavre/version.rb +1 -1
  161. data/lib/collavre.rb +3 -0
  162. metadata +82 -2
  163. data/app/services/collavre/openclaw_abort_service.rb +0 -45
@@ -0,0 +1,318 @@
1
+ import { createEditor, $getRoot, $isTextNode, $createParagraphNode } from "lexical"
2
+ import { $generateNodesFromDOM } from "@lexical/html"
3
+ import { lexicalHtmlConfig, normalizeColoredContainers } from "../color_import"
4
+
5
+ // Parse an inline style string into a plain object so order doesn't matter.
6
+ function parseStyleMap(styleText) {
7
+ const map = {}
8
+ ;(styleText || "").split(";").forEach((decl) => {
9
+ const idx = decl.indexOf(":")
10
+ if (idx === -1) return
11
+ const key = decl.slice(0, idx).trim()
12
+ if (key) map[key] = decl.slice(idx + 1).trim()
13
+ })
14
+ return map
15
+ }
16
+
17
+ // Reproduces InlineLexicalEditor's InitialContentPlugin import path: parse the
18
+ // stored HTML, generate nodes, and read back each text node's color style.
19
+ function importColors(html, { withConfig }) {
20
+ const editor = createEditor({
21
+ namespace: "test",
22
+ onError(error) {
23
+ throw error
24
+ },
25
+ html: withConfig ? lexicalHtmlConfig : undefined
26
+ })
27
+
28
+ let result = []
29
+ editor.update(
30
+ () => {
31
+ const root = $getRoot()
32
+ root.getChildren().forEach((child) => child.remove())
33
+ const doc = new DOMParser().parseFromString(html, "text/html")
34
+ if (withConfig) normalizeColoredContainers(doc.body)
35
+ const nodes = $generateNodesFromDOM(editor, doc.body)
36
+ nodes.forEach((node) => {
37
+ if ($isTextNode(node)) {
38
+ const paragraph = $createParagraphNode()
39
+ paragraph.append(node)
40
+ root.append(paragraph)
41
+ } else {
42
+ root.append(node)
43
+ }
44
+ })
45
+ result = root.getAllTextNodes().map((node) => ({
46
+ text: node.getTextContent(),
47
+ style: node.getStyle(),
48
+ formats: ["bold", "italic", "underline", "strikethrough", "subscript", "superscript"].filter(
49
+ (f) => node.hasFormat(f)
50
+ )
51
+ }))
52
+ },
53
+ { discrete: true }
54
+ )
55
+ return result
56
+ }
57
+
58
+ describe("colorAwareSpanImport", () => {
59
+ it("keeps the color on the correct node when stored HTML has inter-block whitespace", () => {
60
+ // Lexical-exported HTML re-serialized by the server keeps a newline between
61
+ // block tags. Lexical drops that whitespace-only text node on import, which
62
+ // shifted positional color re-application onto the wrong node.
63
+ const html =
64
+ '<p><span style="white-space: pre-wrap;">hello</span></p>\n' +
65
+ '<p><span style="color: rgb(255, 0, 0); white-space: pre-wrap;">RED</span></p>'
66
+
67
+ const nodes = importColors(html, { withConfig: true })
68
+ expect(nodes).toEqual([
69
+ { text: "hello", style: "", formats: [] },
70
+ { text: "RED", style: "color: rgb(255, 0, 0)", formats: [] }
71
+ ])
72
+ })
73
+
74
+ it("keeps the color on the right node when a neighbouring pre-wrap span splits on a newline", () => {
75
+ // `white-space: pre-wrap` makes Lexical split this span's text into two
76
+ // nodes on the "\n", turning one DOM text node into two lexical nodes.
77
+ const html =
78
+ '<p><span style="white-space: pre-wrap;">line one\nline two</span></p>' +
79
+ '<p><span style="color: rgb(0, 128, 0); white-space: pre-wrap;">GREEN</span></p>'
80
+
81
+ const nodes = importColors(html, { withConfig: true })
82
+ expect(nodes.map((n) => n.text)).toEqual(["line one", "line two", "GREEN"])
83
+ expect(nodes.find((n) => n.text === "GREEN").style).toBe("color: rgb(0, 128, 0)")
84
+ expect(nodes.find((n) => n.text === "line two").style).toBe("")
85
+ })
86
+
87
+ it("preserves background-color", () => {
88
+ const html =
89
+ '<p><span style="background-color: rgb(255, 255, 0); white-space: pre-wrap;">hi</span></p>'
90
+
91
+ const nodes = importColors(html, { withConfig: true })
92
+ expect(nodes).toEqual([
93
+ { text: "hi", style: "background-color: rgb(255, 255, 0)", formats: [] }
94
+ ])
95
+ })
96
+
97
+ it("preserves inline-style text formatting on a span that also carries color", () => {
98
+ // Pasted content (Google Docs / Word) encodes bold/italic/underline as
99
+ // inline span styles, not <b>/<i> tags. The custom span import must compose
100
+ // those formats with the color binding instead of dropping them.
101
+ const html =
102
+ '<p><span style="color: rgb(255, 0, 0); font-weight: 700; font-style: italic; ' +
103
+ 'text-decoration: underline line-through; white-space: pre-wrap;">styled</span></p>'
104
+
105
+ const nodes = importColors(html, { withConfig: true })
106
+ expect(nodes).toHaveLength(1)
107
+ expect(nodes[0].text).toBe("styled")
108
+ expect(nodes[0].style).toBe("color: rgb(255, 0, 0)")
109
+ expect(nodes[0].formats.sort()).toEqual(
110
+ ["bold", "italic", "strikethrough", "underline"].sort()
111
+ )
112
+ })
113
+
114
+ it("preserves non-format text styles (font-size/family/transform) on a colored span", () => {
115
+ // The custom span import must carry the span's FULL style, not just color:
116
+ // a colored span can also set font-size / font-family / text-transform /
117
+ // letter-spacing, and Lexical's default conversion ignores them, so cherry-
118
+ // picking color alone drops the rest on reopen.
119
+ const html =
120
+ '<p><span style="color: rgb(255, 0, 0); font-size: 20px; font-family: Georgia; ' +
121
+ 'text-transform: uppercase; letter-spacing: 2px; white-space: pre-wrap;">big</span></p>'
122
+
123
+ const nodes = importColors(html, { withConfig: true })
124
+ expect(nodes).toHaveLength(1)
125
+ expect(nodes[0].text).toBe("big")
126
+ // white-space (Lexical's own artifact) is intentionally dropped; everything
127
+ // else survives.
128
+ expect(parseStyleMap(nodes[0].style)).toEqual({
129
+ color: "rgb(255, 0, 0)",
130
+ "font-size": "20px",
131
+ "font-family": "Georgia",
132
+ "text-transform": "uppercase",
133
+ "letter-spacing": "2px"
134
+ })
135
+ expect(nodes[0].formats).toEqual([])
136
+ })
137
+
138
+ it("carries an unmapped font-weight/font-style/vertical-align value instead of dropping it", () => {
139
+ // applyTextFormatFromStyle only maps font-weight 700/bold, font-style italic
140
+ // and vertical-align sub/super to Lexical formats. A value it does NOT map
141
+ // (font-weight:800, font-style:oblique, vertical-align:middle) is not a
142
+ // format, so it must survive as carried inline style — the old positional
143
+ // collector copied the full style string and kept these.
144
+ const nodes = importColors(
145
+ '<p><span style="color: rgb(255, 0, 0); font-weight: 800; font-style: oblique; ' +
146
+ 'vertical-align: middle; white-space: pre-wrap;">heavy</span></p>',
147
+ { withConfig: true }
148
+ )
149
+ expect(nodes).toHaveLength(1)
150
+ expect(nodes[0].text).toBe("heavy")
151
+ expect(parseStyleMap(nodes[0].style)).toEqual({
152
+ color: "rgb(255, 0, 0)",
153
+ "font-weight": "800",
154
+ "font-style": "oblique",
155
+ "vertical-align": "middle"
156
+ })
157
+ // None of these unmapped values map to a Lexical format.
158
+ expect(nodes[0].formats).toEqual([])
159
+ })
160
+
161
+ it("does NOT carry a mapped font-weight/font-style value (it becomes a format)", () => {
162
+ // Guards the value-aware exclusion: 700/bold/italic still convert to Lexical
163
+ // formats and must not also appear in the carried node style (double-represent).
164
+ const nodes = importColors(
165
+ '<p><span style="color: rgb(255, 0, 0); font-weight: 700; font-style: italic; ' +
166
+ 'white-space: pre-wrap;">styled</span></p>',
167
+ { withConfig: true }
168
+ )
169
+ expect(nodes).toHaveLength(1)
170
+ expect(parseStyleMap(nodes[0].style)).toEqual({ color: "rgb(255, 0, 0)" })
171
+ expect(nodes[0].formats.sort()).toEqual(["bold", "italic"].sort())
172
+ })
173
+
174
+ it("preserves non-format text styles on a non-span colored block element", () => {
175
+ // Same generality for the block path: a colored <p>/<li> carrying font-size
176
+ // etc. must keep those styles when normalizeColoredContainers wraps its text.
177
+ const html = '<p style="color: rgb(255, 0, 0); font-size: 20px;">big block</p>'
178
+
179
+ const nodes = importColors(html, { withConfig: true })
180
+ expect(nodes).toHaveLength(1)
181
+ expect(nodes[0].text).toBe("big block")
182
+ expect(parseStyleMap(nodes[0].style)).toEqual({
183
+ color: "rgb(255, 0, 0)",
184
+ "font-size": "20px"
185
+ })
186
+ })
187
+
188
+ it("keeps an inner span's own color instead of inheriting the outer color", () => {
189
+ // Pasted content nests colored spans. The outer span's after-callback runs
190
+ // after the inner span is converted, so it must merge (inner color wins)
191
+ // rather than clobber every descendant with the outer color.
192
+ const html =
193
+ '<p><span style="color: rgb(255, 0, 0);">outer ' +
194
+ '<span style="color: rgb(0, 0, 255);">inner</span></span></p>'
195
+
196
+ const nodes = importColors(html, { withConfig: true })
197
+ expect(nodes.find((n) => n.text === "outer ").style).toBe("color: rgb(255, 0, 0)")
198
+ expect(nodes.find((n) => n.text === "inner").style).toBe("color: rgb(0, 0, 255)")
199
+ })
200
+
201
+ it("preserves an inner span's background-color while inheriting the outer color", () => {
202
+ const html =
203
+ '<p><span style="color: rgb(255, 0, 0);">outer ' +
204
+ '<span style="background-color: rgb(255, 255, 0);">inner</span></span></p>'
205
+
206
+ const nodes = importColors(html, { withConfig: true })
207
+ const inner = nodes.find((n) => n.text === "inner")
208
+ expect(parseStyleMap(inner.style)).toEqual({
209
+ "background-color": "rgb(255, 255, 0)",
210
+ color: "rgb(255, 0, 0)"
211
+ })
212
+ })
213
+
214
+ it("imports color carried on a non-span block element (e.g. <p style=color>)", () => {
215
+ // Legacy / Trix-migrated / pasted content can put color on a block element
216
+ // instead of a span. The span importer can't see it; normalizeColoredContainers
217
+ // wraps the block's direct text in a span so the color survives reopen.
218
+ const html = '<p style="color: rgb(255, 0, 0);">red paragraph</p>'
219
+
220
+ const nodes = importColors(html, { withConfig: true })
221
+ expect(nodes).toEqual([
222
+ { text: "red paragraph", style: "color: rgb(255, 0, 0)", formats: [] }
223
+ ])
224
+ })
225
+
226
+ it("preserves a non-span block element's own text formatting alongside its color", () => {
227
+ // Pasted content can put color AND text formatting on a block element, e.g.
228
+ // <p style="color:red; font-weight:700; font-style:italic">. The wrapper span
229
+ // must carry those format styles too, or normalization drops the bold/italic.
230
+ const html =
231
+ '<p style="color: rgb(255, 0, 0); font-weight: 700; font-style: italic; ' +
232
+ 'text-decoration: underline;">styled block</p>'
233
+
234
+ const nodes = importColors(html, { withConfig: true })
235
+ expect(nodes).toHaveLength(1)
236
+ expect(nodes[0].text).toBe("styled block")
237
+ expect(nodes[0].style).toBe("color: rgb(255, 0, 0)")
238
+ expect(nodes[0].formats.sort()).toEqual(["bold", "italic", "underline"].sort())
239
+ })
240
+
241
+ it("lets an inner colored span override the color of its non-span block parent", () => {
242
+ const html =
243
+ '<p style="color: rgb(255, 0, 0);">outer ' +
244
+ '<span style="color: rgb(0, 0, 255);">inner</span></p>'
245
+
246
+ const nodes = importColors(html, { withConfig: true })
247
+ expect(nodes.find((n) => n.text === "outer ").style).toBe("color: rgb(255, 0, 0)")
248
+ expect(nodes.find((n) => n.text === "inner").style).toBe("color: rgb(0, 0, 255)")
249
+ })
250
+
251
+ it("lets a nested span reset a text format the outer span turned on", () => {
252
+ // applyTextFormatFromStyle only turns formats ON, so without dimension
253
+ // tracking the outer span's after-callback re-bolds the inner text even
254
+ // though the inner span explicitly reset font-weight. The nearer span must
255
+ // win: an inner font-weight:normal un-bolds, while an inner span with no
256
+ // weight declaration still inherits the outer bold.
257
+ const reset = importColors(
258
+ '<p><span style="color: rgb(255, 0, 0); font-weight: 700;">bold ' +
259
+ '<span style="color: rgb(0, 0, 255); font-weight: normal;">normal</span></span></p>',
260
+ { withConfig: true }
261
+ )
262
+ expect(reset.find((n) => n.text === "bold ").formats).toEqual(["bold"])
263
+ expect(reset.find((n) => n.text === "normal").formats).toEqual([])
264
+
265
+ const inherit = importColors(
266
+ '<p><span style="color: rgb(255, 0, 0); font-weight: 700;">bold ' +
267
+ '<span style="color: rgb(0, 0, 255);">still</span></span></p>',
268
+ { withConfig: true }
269
+ )
270
+ expect(inherit.find((n) => n.text === "still").formats).toEqual(["bold"])
271
+ })
272
+
273
+ it("clears a tag-applied format when a colored span resets it", () => {
274
+ // Lexical's default conversion toggles bold on for the <b> tag before the
275
+ // colored span's after-callback runs. A reset value on the span (an
276
+ // inherited CSS property) must actively un-bold the text, not just claim the
277
+ // dimension — otherwise reopened/pasted content reopens as bold.
278
+ const nodes = importColors(
279
+ '<p><b><span style="color: rgb(255, 0, 0); font-weight: normal;">normal</span></b></p>',
280
+ { withConfig: true }
281
+ )
282
+ expect(nodes).toEqual([
283
+ { text: "normal", style: "color: rgb(255, 0, 0)", formats: [] }
284
+ ])
285
+
286
+ // Same for italic via <i> + font-style: normal.
287
+ const italic = importColors(
288
+ '<p><i><span style="color: rgb(255, 0, 0); font-style: normal;">upright</span></i></p>',
289
+ { withConfig: true }
290
+ )
291
+ expect(italic.find((n) => n.text === "upright").formats).toEqual([])
292
+ })
293
+
294
+ it("keeps an inherited strikethrough when a colored span only adds underline", () => {
295
+ // text-decoration propagates to descendants additively in CSS: an inner
296
+ // `text-decoration: underline` does NOT remove an ancestor's line-through.
297
+ // So a colored span must keep the tag-applied strikethrough and add underline
298
+ // (add-only), unlike the inherited font-weight/font-style reset above.
299
+ const nodes = importColors(
300
+ '<p><s><span style="color: rgb(255, 0, 0); text-decoration: underline;">both</span></s></p>',
301
+ { withConfig: true }
302
+ )
303
+ expect(nodes.find((n) => n.text === "both").formats.sort()).toEqual(
304
+ ["strikethrough", "underline"].sort()
305
+ )
306
+ })
307
+
308
+ it("demonstrates the drift the fix removes (no config = color on wrong/lost node)", () => {
309
+ const html =
310
+ '<p><span style="white-space: pre-wrap;">hello</span></p>\n' +
311
+ '<p><span style="color: rgb(255, 0, 0); white-space: pre-wrap;">RED</span></p>'
312
+
313
+ // Without the color-aware import, Lexical's default span conversion ignores
314
+ // color entirely, so "RED" comes back uncolored.
315
+ const nodes = importColors(html, { withConfig: false })
316
+ expect(nodes.find((n) => n.text === "RED").style).not.toContain("color: rgb(255, 0, 0)")
317
+ })
318
+ })
@@ -0,0 +1,259 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import {
5
+ createEditor,
6
+ $getRoot,
7
+ $createParagraphNode,
8
+ $isElementNode,
9
+ $isLineBreakNode,
10
+ $isTextNode
11
+ } from "lexical"
12
+ import { HeadingNode, QuoteNode } from "@lexical/rich-text"
13
+ import { CodeNode, CodeHighlightNode } from "@lexical/code"
14
+ import { ListItemNode, ListNode } from "@lexical/list"
15
+ import { LinkNode, AutoLinkNode } from "@lexical/link"
16
+ import { $generateHtmlFromNodes, $generateNodesFromDOM } from "@lexical/html"
17
+ import { minimizeContentHtml } from "../minimize_html"
18
+
19
+ const theme = {
20
+ paragraph: "lexical-paragraph",
21
+ quote: "lexical-quote",
22
+ heading: { h1: "lexical-heading-h1", h2: "lexical-heading-h2", h3: "lexical-heading-h3" },
23
+ list: { ul: "lexical-list-ul", ol: "lexical-list-ol", listitem: "lexical-list-item" },
24
+ code: "lexical-code-block",
25
+ link: "lexical-link",
26
+ text: {
27
+ bold: "lexical-text-bold",
28
+ italic: "lexical-text-italic",
29
+ underline: "lexical-text-underline",
30
+ strikethrough: "lexical-text-strike",
31
+ code: "lexical-text-code"
32
+ }
33
+ }
34
+
35
+ function newEditor() {
36
+ return createEditor({
37
+ namespace: "test",
38
+ theme,
39
+ nodes: [HeadingNode, QuoteNode, CodeNode, CodeHighlightNode, ListItemNode, ListNode, LinkNode, AutoLinkNode],
40
+ onError: (e) => { throw e }
41
+ })
42
+ }
43
+
44
+ // Replicate the editor's serialize step: render input -> Lexical HTML.
45
+ function serialize(inputHtml) {
46
+ const editor = newEditor()
47
+ editor.update(() => {
48
+ const root = $getRoot()
49
+ root.getChildren().forEach((c) => c.remove())
50
+ const doc = new DOMParser().parseFromString(inputHtml, "text/html")
51
+ root.append(...$generateNodesFromDOM(editor, doc.body))
52
+ }, { discrete: true })
53
+ let out = ""
54
+ editor.getEditorState().read(() => { out = $generateHtmlFromNodes(editor) })
55
+ return out
56
+ }
57
+
58
+ // Wrap raw Lexical HTML the way InlineLexicalEditor does, then minimize.
59
+ function minimize(lexicalHtml) {
60
+ const doc = new DOMParser().parseFromString(`<div>${lexicalHtml}</div>`, "text/html")
61
+ return minimizeContentHtml(doc.body.firstElementChild)
62
+ }
63
+
64
+ // Re-import minimized HTML and re-serialize: the second pass must be identical
65
+ // (stable round-trip) and preserve every text format flag.
66
+ function reimportFormats(minimizedHtml) {
67
+ const editor = newEditor()
68
+ editor.update(() => {
69
+ const root = $getRoot()
70
+ root.getChildren().forEach((c) => c.remove())
71
+ const doc = new DOMParser().parseFromString(minimizedHtml, "text/html")
72
+ const nodes = $generateNodesFromDOM(editor, doc.body)
73
+ // Mirror InitialContentPlugin: group top-level inline/text runs into a <p>.
74
+ let pending = null
75
+ const flush = () => { if (pending) { root.append(pending); pending = null } }
76
+ nodes.forEach((node) => {
77
+ const inlineLeaf =
78
+ $isTextNode(node) ||
79
+ $isLineBreakNode(node) ||
80
+ ($isElementNode(node) && node.isInline())
81
+ if (inlineLeaf) {
82
+ if (!pending) pending = $createParagraphNode()
83
+ pending.append(node)
84
+ return
85
+ }
86
+ flush()
87
+ root.append(node)
88
+ })
89
+ flush()
90
+ }, { discrete: true })
91
+ let out = ""
92
+ editor.getEditorState().read(() => { out = $generateHtmlFromNodes(editor) })
93
+ return out
94
+ }
95
+
96
+ describe("minimizeContentHtml", () => {
97
+ test("plain single line -> bare text, no wrapper", () => {
98
+ expect(minimize(serialize("<p>Hello World</p>"))).toBe("Hello World")
99
+ })
100
+
101
+ test("strips the white-space:pre-wrap noise spans", () => {
102
+ const out = minimize(serialize("<p>Hello World</p>"))
103
+ expect(out).not.toContain("white-space")
104
+ expect(out).not.toContain("<span")
105
+ })
106
+
107
+ test("bold keeps a single semantic tag with its class", () => {
108
+ expect(minimize(serialize("<p><b>x</b></p>"))).toBe(
109
+ '<strong class="lexical-text-bold">x</strong>'
110
+ )
111
+ })
112
+
113
+ test("italic keeps a single em with its class", () => {
114
+ expect(minimize(serialize("<p><i>x</i></p>"))).toBe(
115
+ '<em class="lexical-text-italic">x</em>'
116
+ )
117
+ })
118
+
119
+ test("partially styled line keeps text + minimal styled tag", () => {
120
+ expect(minimize(serialize("<p>Hello <b>World</b></p>"))).toBe(
121
+ 'Hello <strong class="lexical-text-bold">World</strong>'
122
+ )
123
+ })
124
+
125
+ test("nested bold+italic collapses outer duplicate but keeps both formats", () => {
126
+ expect(minimize(serialize("<p><b><i>x</i></b></p>"))).toBe(
127
+ '<i><strong class="lexical-text-bold lexical-text-italic">x</strong></i>'
128
+ )
129
+ })
130
+
131
+ test("multiple paragraphs keep one <p> per line", () => {
132
+ expect(minimize(serialize("<p>line1</p><p>line2</p>"))).toBe(
133
+ '<p class="lexical-paragraph">line1</p><p class="lexical-paragraph">line2</p>'
134
+ )
135
+ })
136
+
137
+ test("heading stays structural", () => {
138
+ expect(minimize(serialize("<h1>Title</h1>"))).toBe(
139
+ '<h1 class="lexical-heading-h1">Title</h1>'
140
+ )
141
+ })
142
+
143
+ test("list stays structural", () => {
144
+ expect(minimize(serialize("<ul><li>a</li><li>b</li></ul>"))).toBe(
145
+ '<ul class="lexical-list-ul"><li value="1" class="lexical-list-item">a</li>' +
146
+ '<li value="2" class="lexical-list-item">b</li></ul>'
147
+ )
148
+ })
149
+
150
+ test("link keeps href + class, drops inner span", () => {
151
+ const out = minimize(serialize('<p><a href="https://a.com">x</a></p>'))
152
+ expect(out).toContain('href="https://a.com"')
153
+ expect(out).toContain('class="lexical-link"')
154
+ expect(out).not.toContain("<span")
155
+ expect(out).not.toContain("white-space")
156
+ })
157
+
158
+ test("code block preserves structure and highlight classes", () => {
159
+ const out = minimize(serialize("<pre><code>const a = 1</code></pre>"))
160
+ expect(out).toContain("lexical-code-block")
161
+ expect(out).toContain("const a = 1")
162
+ expect(out).not.toContain("white-space")
163
+ expect(out).not.toContain("spellcheck")
164
+ })
165
+
166
+ test("empty paragraph minimizes to empty/near-empty", () => {
167
+ const out = minimize(serialize("<p></p>"))
168
+ expect(out.replace(/<br\s*\/?>/g, "").trim()).toBe("")
169
+ })
170
+
171
+ test("keeps pre-wrap span when text has 2+ consecutive spaces", () => {
172
+ // The render container has no `white-space: pre-wrap`, so significant
173
+ // whitespace must survive in the persisted markup.
174
+ const lexical =
175
+ '<p class="lexical-paragraph">' +
176
+ '<span style="white-space: pre-wrap;">foo bar</span></p>'
177
+ const out = minimize(lexical)
178
+ expect(out).toContain("foo bar")
179
+ expect(out).toContain("white-space")
180
+ })
181
+
182
+ test("keeps pre-wrap span for leading indentation", () => {
183
+ const lexical =
184
+ '<p class="lexical-paragraph">' +
185
+ '<span style="white-space: pre-wrap;"> indented</span></p>'
186
+ const out = minimize(lexical)
187
+ expect(out).toContain(" indented")
188
+ expect(out).toContain("white-space")
189
+ })
190
+
191
+ test("a single trailing space stays minimal (not significant)", () => {
192
+ // The "Hello " run has one trailing space — renders fine bare, so no span.
193
+ expect(minimize(serialize("<p>Hello <b>World</b></p>"))).toBe(
194
+ 'Hello <strong class="lexical-text-bold">World</strong>'
195
+ )
196
+ })
197
+
198
+ test("keeps pre-wrap on both sides when a space run straddles a format boundary", () => {
199
+ // The live editor serializes a bolded " bar" (within a 2-space run) as
200
+ // `foo ` + `<b><strong> bar</strong></b>`: the second space is nested inside
201
+ // the bold. Each node holds one edge space, so dropping pre-wrap from both
202
+ // would collapse the pair. Both sides must keep pre-wrap.
203
+ const lexical =
204
+ '<p class="lexical-paragraph">' +
205
+ '<span style="white-space: pre-wrap;">foo </span>' +
206
+ '<b><strong class="lexical-text-bold" style="white-space: pre-wrap;"> bar</strong></b>' +
207
+ "</p>"
208
+ const out = minimize(lexical)
209
+ expect(out).toBe(
210
+ '<span style="white-space: pre-wrap;">foo </span>' +
211
+ '<strong class="lexical-text-bold" style="white-space: pre-wrap;"> bar</strong>'
212
+ )
213
+ // pre-wrap survives on both the plain run and the styled run.
214
+ expect(out.match(/white-space/g)).toHaveLength(2)
215
+ })
216
+
217
+ test("boundary space across a paragraph break does NOT keep pre-wrap", () => {
218
+ // Different blocks have separate inline contexts — trailing space of one
219
+ // line and leading space of the next never combine, so neither is significant.
220
+ const lexical =
221
+ '<p class="lexical-paragraph"><span style="white-space: pre-wrap;">foo </span></p>' +
222
+ '<p class="lexical-paragraph"><span style="white-space: pre-wrap;"> bar</span></p>'
223
+ const out = minimize(lexical)
224
+ expect(out).not.toContain("white-space")
225
+ })
226
+
227
+ test("soft line break keeps its <p> wrapper", () => {
228
+ const out = minimize(serialize("<p>line 1<br>line 2</p>"))
229
+ expect(out).toContain("<br>")
230
+ expect(out.startsWith("<p")).toBe(true)
231
+ expect(out).toContain("line 1")
232
+ expect(out).toContain("line 2")
233
+ })
234
+
235
+ // Round-trip stability: minimized HTML re-imported and re-serialized, then
236
+ // minimized again, must equal the first minimization (no format loss / drift).
237
+ const roundTripCases = [
238
+ "<p>Hello World</p>",
239
+ "<p>Hello <b>World</b></p>",
240
+ "<p><b>x</b></p>",
241
+ "<p><i>x</i></p>",
242
+ "<p><u>x</u></p>",
243
+ "<p><s>x</s></p>",
244
+ "<p><b><i>x</i></b></p>",
245
+ "<p><code>inline</code></p>",
246
+ "<h1>Title</h1>",
247
+ "<p>line1</p><p>line2</p>",
248
+ "<ul><li>a</li><li>b</li></ul>",
249
+ "<blockquote>quoted</blockquote>",
250
+ '<p><a href="https://a.com">link</a></p>',
251
+ "<p>line 1<br>line 2</p>"
252
+ ]
253
+
254
+ test.each(roundTripCases)("round-trip stable: %s", (input) => {
255
+ const first = minimize(serialize(input))
256
+ const second = minimize(reimportFormats(first))
257
+ expect(second).toBe(first)
258
+ })
259
+ })