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.
- checksums.yaml +4 -4
- data/README.md +1 -0
- data/app/assets/stylesheets/collavre/actiontext.css +251 -90
- data/app/assets/stylesheets/collavre/code_highlight.css +7 -201
- data/app/assets/stylesheets/collavre/comments_popup.css +118 -61
- data/app/assets/stylesheets/collavre/creatives.css +11 -2
- data/app/assets/stylesheets/collavre/modal_dialog.css +32 -0
- data/app/assets/stylesheets/collavre/tables.css +91 -0
- data/app/channels/collavre/inbox_badge_channel.rb +30 -0
- data/app/controllers/collavre/api/v1/mobile/agent_events_controller.rb +224 -0
- data/app/controllers/collavre/api/v1/mobile/base_controller.rb +95 -0
- data/app/controllers/collavre/api/v1/mobile/devices_controller.rb +31 -0
- data/app/controllers/collavre/api/v1/mobile/voice_commands_controller.rb +25 -0
- data/app/controllers/collavre/creatives_controller.rb +16 -5
- data/app/controllers/collavre/tasks_controller.rb +13 -4
- data/app/controllers/collavre/topics_controller.rb +49 -1
- data/app/controllers/collavre/typo_corrections_controller.rb +39 -0
- data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +16 -1
- data/app/controllers/concerns/collavre/users_controller/registration.rb +41 -1
- data/app/helpers/collavre/application_helper.rb +1 -0
- data/app/javascript/collavre.js +2 -0
- data/app/javascript/components/ImageResizer.jsx +9 -3
- data/app/javascript/components/InlineLexicalEditor.jsx +155 -38
- data/app/javascript/components/creative_tree_row.js +20 -3
- data/app/javascript/components/plugins/list_tab_indent_plugin.jsx +16 -0
- data/app/javascript/components/plugins/table_hover_actions_plugin.jsx +405 -0
- data/app/javascript/controllers/__tests__/inbox_badge_controller.test.js +73 -0
- data/app/javascript/controllers/comment_controller.js +5 -4
- data/app/javascript/controllers/comment_version_controller.js +2 -1
- data/app/javascript/controllers/comments/__tests__/form_controller_double_submit.test.js +159 -0
- data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +3 -2
- data/app/javascript/controllers/comments/__tests__/topics_controller_delete.test.js +94 -0
- data/app/javascript/controllers/comments/form_controller.js +21 -5
- data/app/javascript/controllers/comments/list_controller.js +18 -17
- data/app/javascript/controllers/comments/presence_controller.js +2 -1
- data/app/javascript/controllers/comments/topics_controller.js +14 -8
- data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +150 -0
- data/app/javascript/controllers/creatives/import_controller.js +2 -1
- data/app/javascript/controllers/creatives/select_mode_controller.js +2 -1
- data/app/javascript/controllers/creatives/tree_controller.js +142 -1
- data/app/javascript/controllers/image_lightbox_controller.js +2 -1
- data/app/javascript/controllers/inbox_badge_controller.js +33 -0
- data/app/javascript/controllers/index.js +4 -1
- data/app/javascript/controllers/share_modal_controller.js +4 -3
- data/app/javascript/controllers/topic_search_controller.js +2 -1
- data/app/javascript/creatives/drag_drop/event_handlers.js +14 -5
- data/app/javascript/creatives/topic_move_members_popup.js +156 -0
- data/app/javascript/creatives/tree_renderer.js +11 -0
- data/app/javascript/lib/__tests__/turbo_confirm.test.js +81 -0
- data/app/javascript/lib/__tests__/typo_correction.test.js +192 -0
- data/app/javascript/lib/api/__tests__/api_error.test.js +96 -0
- data/app/javascript/lib/api/__tests__/queue_manager.test.js +88 -1
- data/app/javascript/lib/api/api_error.js +108 -0
- data/app/javascript/lib/api/queue_manager.js +38 -4
- data/app/javascript/lib/common_popup.js +18 -5
- data/app/javascript/lib/editor/__tests__/code_edit_view_token_parity.test.js +121 -0
- data/app/javascript/lib/editor/__tests__/code_language_roundtrip.test.js +152 -0
- data/app/javascript/lib/editor/__tests__/code_languages.test.js +93 -0
- data/app/javascript/lib/editor/code_languages.js +173 -0
- data/app/javascript/lib/editor/code_token_theme.js +41 -0
- data/app/javascript/lib/lexical/__tests__/image_focus.test.js +139 -0
- data/app/javascript/lib/lexical/__tests__/list_tab_indent.test.js +633 -0
- data/app/javascript/lib/lexical/__tests__/markdown_serialize.test.js +627 -0
- data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +20 -1
- data/app/javascript/lib/lexical/__tests__/selection_boundary.test.js +88 -0
- data/app/javascript/lib/lexical/__tests__/table_transformer.test.js +163 -0
- data/app/javascript/lib/lexical/__tests__/trailing_paragraph.test.js +104 -0
- data/app/javascript/lib/lexical/list_tab_indent.js +210 -0
- data/app/javascript/lib/lexical/markdown_serialize.js +320 -0
- data/app/javascript/lib/lexical/selection_boundary.js +58 -0
- data/app/javascript/lib/lexical/table_transformer.js +182 -0
- data/app/javascript/lib/lexical/trailing_paragraph.js +29 -0
- data/app/javascript/lib/turbo_confirm.js +46 -0
- data/app/javascript/lib/typo_correction.js +146 -0
- data/app/javascript/lib/utils/__tests__/confirm_dialog.test.js +88 -0
- data/app/javascript/lib/utils/__tests__/dialog.test.js +92 -0
- data/app/javascript/lib/utils/__tests__/markdown.test.js +153 -0
- data/app/javascript/lib/utils/__tests__/sanitize_description.test.js +68 -0
- data/app/javascript/lib/utils/__tests__/table_download.test.js +93 -0
- data/app/javascript/lib/utils/confirm_dialog.js +10 -0
- data/app/javascript/lib/utils/dialog.js +300 -0
- data/app/javascript/lib/utils/markdown.js +154 -67
- data/app/javascript/lib/utils/sanitize_description.js +31 -0
- data/app/javascript/lib/utils/table_download.js +15 -0
- data/app/javascript/modules/__tests__/typo_corrector.test.js +365 -0
- data/app/javascript/modules/creative_row_editor.js +110 -70
- data/app/javascript/modules/export_to_markdown.js +2 -1
- data/app/javascript/modules/lexical_inline_editor.jsx +2 -1
- data/app/javascript/modules/slide_view.js +11 -2
- data/app/javascript/modules/typo_corrector.js +534 -0
- data/app/jobs/collavre/ai_agent_job.rb +7 -4
- data/app/jobs/collavre/compress_job.rb +6 -2
- data/app/models/collavre/comment/broadcastable.rb +46 -7
- data/app/models/collavre/comment/notifiable.rb +14 -4
- data/app/models/collavre/comment.rb +79 -31
- data/app/models/collavre/creative/describable.rb +89 -10
- data/app/models/collavre/task.rb +15 -0
- data/app/models/collavre/user.rb +57 -1
- data/app/services/collavre/ai_client.rb +28 -10
- data/app/services/collavre/auto_theme_generator.rb +1 -1
- data/app/services/collavre/creatives/index_query.rb +85 -16
- data/app/services/collavre/creatives/tree_builder.rb +2 -1
- data/app/services/collavre/gemini_parent_recommender.rb +1 -1
- data/app/services/collavre/inbox_reply_service.rb +5 -0
- data/app/services/collavre/markdown_converter.rb +13 -3
- data/app/services/collavre/mobile/event_summarizer.rb +40 -0
- data/app/services/collavre/orchestration/agent_orchestrator.rb +33 -7
- data/app/services/collavre/orchestration/arbiter.rb +16 -0
- data/app/services/collavre/orchestration/matcher.rb +79 -4
- data/app/services/collavre/orchestration/policy_resolver.rb +4 -3
- data/app/services/collavre/orchestration/stuck_detector.rb +141 -34
- data/app/services/collavre/tools/creative_batch_service.rb +3 -2
- data/app/services/collavre/tools/creative_create_service.rb +8 -8
- data/app/services/collavre/tools/creative_update_service.rb +23 -8
- data/app/services/collavre/typo_corrector.rb +188 -0
- data/app/views/collavre/comments/_comment.html.erb +5 -0
- data/app/views/collavre/comments/_comments_popup.html.erb +14 -1
- data/app/views/collavre/creatives/_inline_edit_form.html.erb +1 -0
- data/app/views/collavre/creatives/_topic_move_members_modal.html.erb +42 -0
- data/app/views/collavre/creatives/index.html.erb +14 -1
- data/app/views/collavre/creatives/slide_view.html.erb +1 -1
- data/app/views/collavre/users/show.html.erb +3 -0
- data/app/views/collavre/users/typo_correction.html.erb +50 -0
- data/app/views/layouts/collavre/slide.html.erb +1 -0
- data/config/locales/comments.en.yml +15 -0
- data/config/locales/comments.ko.yml +15 -0
- data/config/locales/integrations.en.yml +1 -1
- data/config/locales/integrations.ko.yml +1 -1
- data/config/locales/mobile.en.yml +16 -0
- data/config/locales/mobile.ko.yml +16 -0
- data/config/locales/orchestration.en.yml +1 -0
- data/config/locales/orchestration.ko.yml +1 -0
- data/config/locales/users.en.yml +15 -0
- data/config/locales/users.ko.yml +15 -0
- data/config/routes.rb +13 -0
- data/db/migrate/20260612000000_add_topic_concurrency_defer_to_comments.rb +38 -0
- data/db/migrate/20260617090000_add_typo_correction_settings_to_users.rb +18 -0
- data/db/seeds.rb +51 -0
- data/lib/collavre/version.rb +1 -1
- data/lib/generators/collavre/install/install_generator.rb +1 -0
- metadata +55 -2
- data/app/services/collavre/tools/description_normalizable.rb +0 -16
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createEditor,
|
|
3
|
+
$getRoot,
|
|
4
|
+
$createParagraphNode,
|
|
5
|
+
$createTextNode,
|
|
6
|
+
$createLineBreakNode,
|
|
7
|
+
$isTextNode,
|
|
8
|
+
$isLineBreakNode,
|
|
9
|
+
$isElementNode,
|
|
10
|
+
DecoratorNode
|
|
11
|
+
} from "lexical"
|
|
12
|
+
import { HeadingNode, QuoteNode, $createHeadingNode } from "@lexical/rich-text"
|
|
13
|
+
import { ListNode, ListItemNode } from "@lexical/list"
|
|
14
|
+
import { LinkNode } from "@lexical/link"
|
|
15
|
+
import { CodeNode, CodeHighlightNode } from "@lexical/code"
|
|
16
|
+
import { $generateNodesFromDOM } from "@lexical/html"
|
|
17
|
+
import { lexicalHtmlConfig, normalizeColoredContainers } from "../color_import"
|
|
18
|
+
import {
|
|
19
|
+
colorSpanMarkup,
|
|
20
|
+
imageMarkup,
|
|
21
|
+
videoMarkup,
|
|
22
|
+
attachmentMarkup,
|
|
23
|
+
lexicalToMarkdown,
|
|
24
|
+
normalizeMarkdownBlankLines,
|
|
25
|
+
splitBlankLineParagraphs
|
|
26
|
+
} from "../markdown_serialize"
|
|
27
|
+
|
|
28
|
+
// Minimal DecoratorNode stand-in: the production image/video/attachment nodes
|
|
29
|
+
// are .jsx and can't load under native-ESM Jest, but the serializer only
|
|
30
|
+
// duck-types getType()/getSrc()/etc., so a plain-JS DecoratorNode subclass
|
|
31
|
+
// exercises the same export path — including the top-level (root child) case.
|
|
32
|
+
class TestImageNode extends DecoratorNode {
|
|
33
|
+
static getType() {
|
|
34
|
+
return "image"
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
static clone(node) {
|
|
38
|
+
return new TestImageNode(node.__src, node.__altText, node.__key)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
constructor(src = "", altText = "", key) {
|
|
42
|
+
super(key)
|
|
43
|
+
this.__src = src
|
|
44
|
+
this.__altText = altText
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
getSrc() {
|
|
48
|
+
return this.__src
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
getAltText() {
|
|
52
|
+
return this.__altText
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
createDOM() {
|
|
56
|
+
return document.createElement("span")
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
updateDOM() {
|
|
60
|
+
return false
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
decorate() {
|
|
64
|
+
return null
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function buildEditor(builder, extraNodes = []) {
|
|
69
|
+
const editor = createEditor({
|
|
70
|
+
namespace: "test",
|
|
71
|
+
onError(error) {
|
|
72
|
+
throw error
|
|
73
|
+
},
|
|
74
|
+
nodes: [
|
|
75
|
+
HeadingNode,
|
|
76
|
+
QuoteNode,
|
|
77
|
+
ListNode,
|
|
78
|
+
ListItemNode,
|
|
79
|
+
LinkNode,
|
|
80
|
+
CodeNode,
|
|
81
|
+
CodeHighlightNode,
|
|
82
|
+
...extraNodes
|
|
83
|
+
]
|
|
84
|
+
})
|
|
85
|
+
editor.update(
|
|
86
|
+
() => {
|
|
87
|
+
const root = $getRoot()
|
|
88
|
+
root.clear()
|
|
89
|
+
builder(root)
|
|
90
|
+
},
|
|
91
|
+
{ discrete: true }
|
|
92
|
+
)
|
|
93
|
+
return editor
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
describe("colorSpanMarkup", () => {
|
|
97
|
+
it("wraps inner text in a normalized color span", () => {
|
|
98
|
+
expect(colorSpanMarkup("color: rgb(255, 0, 0)", "hi")).toBe(
|
|
99
|
+
'<span style="color: rgb(255, 0, 0)">hi</span>'
|
|
100
|
+
)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it("emits color first, then background-color, regardless of input order", () => {
|
|
104
|
+
expect(
|
|
105
|
+
colorSpanMarkup("background-color: rgb(0, 255, 0); color: #ff0000", "x")
|
|
106
|
+
).toBe('<span style="color: #ff0000; background-color: rgb(0, 255, 0)">x</span>')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it("supports background-color only", () => {
|
|
110
|
+
expect(colorSpanMarkup("background-color: #ffff00", "y")).toBe(
|
|
111
|
+
'<span style="background-color: #ffff00">y</span>'
|
|
112
|
+
)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it("allows CSS custom properties", () => {
|
|
116
|
+
expect(colorSpanMarkup("color: var(--color-danger)", "z")).toBe(
|
|
117
|
+
'<span style="color: var(--color-danger)">z</span>'
|
|
118
|
+
)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it("returns null when no color/background present", () => {
|
|
122
|
+
expect(colorSpanMarkup("font-size: 12px", "n")).toBeNull()
|
|
123
|
+
expect(colorSpanMarkup("", "n")).toBeNull()
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it("drops dangerous CSS values (url, expression, injection)", () => {
|
|
127
|
+
expect(colorSpanMarkup("color: url(javascript:alert(1))", "n")).toBeNull()
|
|
128
|
+
expect(colorSpanMarkup('color: red"><script>', "n")).toBeNull()
|
|
129
|
+
expect(colorSpanMarkup("color: expression(alert(1))", "n")).toBeNull()
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it("HTML-escapes the inner text so it can't become raw markup", () => {
|
|
133
|
+
expect(colorSpanMarkup("color: #ff0000", "<img src=x onerror=alert(1)>")).toBe(
|
|
134
|
+
'<span style="color: #ff0000"><img src=x onerror=alert(1)></span>'
|
|
135
|
+
)
|
|
136
|
+
expect(colorSpanMarkup("color: #ff0000", "a & b < c")).toBe(
|
|
137
|
+
'<span style="color: #ff0000">a & b < c</span>'
|
|
138
|
+
)
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
describe("decorator markup", () => {
|
|
143
|
+
it("emits a clean <img> and drops the inherit sentinel", () => {
|
|
144
|
+
expect(imageMarkup({ src: "/x.png", altText: "cat", width: "inherit", height: "inherit" })).toBe(
|
|
145
|
+
'<img src="/x.png" alt="cat">'
|
|
146
|
+
)
|
|
147
|
+
expect(imageMarkup({ src: "/x.png", altText: "cat", width: 200, height: 100 })).toBe(
|
|
148
|
+
'<img src="/x.png" alt="cat" width="200" height="100">'
|
|
149
|
+
)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it("escapes attribute values", () => {
|
|
153
|
+
expect(imageMarkup({ src: '/a.png"', altText: "<b>" })).toBe(
|
|
154
|
+
'<img src="/a.png"" alt="<b>">'
|
|
155
|
+
)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it("emits a controls <video>", () => {
|
|
159
|
+
expect(videoMarkup({ src: "/v.mp4" })).toBe('<video controls src="/v.mp4"></video>')
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it("emits a download <a> with filesize", () => {
|
|
163
|
+
expect(attachmentMarkup({ src: "/f.pdf", filename: "doc.pdf", filesize: 1234 })).toBe(
|
|
164
|
+
'<a href="/f.pdf" download="doc.pdf" data-filesize="1234">doc.pdf</a>'
|
|
165
|
+
)
|
|
166
|
+
expect(attachmentMarkup({ src: "/f.pdf", filename: "doc.pdf" })).toBe(
|
|
167
|
+
'<a href="/f.pdf" download="doc.pdf">doc.pdf</a>'
|
|
168
|
+
)
|
|
169
|
+
})
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
describe("lexicalToMarkdown", () => {
|
|
173
|
+
it("serializes a plain paragraph", () => {
|
|
174
|
+
const editor = buildEditor((root) => {
|
|
175
|
+
const p = $createParagraphNode()
|
|
176
|
+
p.append($createTextNode("hello world"))
|
|
177
|
+
root.append(p)
|
|
178
|
+
})
|
|
179
|
+
expect(lexicalToMarkdown(editor)).toBe("hello world")
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it("serializes headings and bold via upstream transformers", () => {
|
|
183
|
+
const editor = buildEditor((root) => {
|
|
184
|
+
const h = $createHeadingNode("h1")
|
|
185
|
+
h.append($createTextNode("Title"))
|
|
186
|
+
root.append(h)
|
|
187
|
+
const p = $createParagraphNode()
|
|
188
|
+
const bold = $createTextNode("strong")
|
|
189
|
+
bold.toggleFormat("bold")
|
|
190
|
+
p.append(bold)
|
|
191
|
+
root.append(p)
|
|
192
|
+
})
|
|
193
|
+
expect(lexicalToMarkdown(editor)).toBe("# Title\n\n**strong**")
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it("wraps colored text in a span fragment", () => {
|
|
197
|
+
const editor = buildEditor((root) => {
|
|
198
|
+
const p = $createParagraphNode()
|
|
199
|
+
const t = $createTextNode("red")
|
|
200
|
+
t.setStyle("color: rgb(255, 0, 0)")
|
|
201
|
+
p.append(t)
|
|
202
|
+
root.append(p)
|
|
203
|
+
})
|
|
204
|
+
expect(lexicalToMarkdown(editor)).toBe('<span style="color: rgb(255, 0, 0)">red</span>')
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it("composes bold with color (format inside the span)", () => {
|
|
208
|
+
const editor = buildEditor((root) => {
|
|
209
|
+
const p = $createParagraphNode()
|
|
210
|
+
const t = $createTextNode("hot")
|
|
211
|
+
t.setStyle("color: #ff0000")
|
|
212
|
+
t.toggleFormat("bold")
|
|
213
|
+
p.append(t)
|
|
214
|
+
root.append(p)
|
|
215
|
+
})
|
|
216
|
+
expect(lexicalToMarkdown(editor)).toBe('<span style="color: #ff0000">**hot**</span>')
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it("serializes a top-level media decorator (root child, not inside a paragraph)", () => {
|
|
220
|
+
const editor = buildEditor((root) => {
|
|
221
|
+
root.append(new TestImageNode("/u.png", "up"))
|
|
222
|
+
}, [TestImageNode])
|
|
223
|
+
// Without an element-type transformer, a top-level decorator falls back to
|
|
224
|
+
// getTextContent() ("") and the image is silently dropped from markdown_source.
|
|
225
|
+
expect(lexicalToMarkdown(editor)).toBe('<img src="/u.png" alt="up">')
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it("serializes a top-level media decorator alongside text", () => {
|
|
229
|
+
const editor = buildEditor((root) => {
|
|
230
|
+
const p = $createParagraphNode()
|
|
231
|
+
p.append($createTextNode("before"))
|
|
232
|
+
root.append(p)
|
|
233
|
+
root.append(new TestImageNode("/u.png", "up"))
|
|
234
|
+
}, [TestImageNode])
|
|
235
|
+
expect(lexicalToMarkdown(editor)).toBe('before\n\n<img src="/u.png" alt="up">')
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it("leaves uncolored neighbours as plain markdown", () => {
|
|
239
|
+
const editor = buildEditor((root) => {
|
|
240
|
+
const p = $createParagraphNode()
|
|
241
|
+
p.append($createTextNode("plain "))
|
|
242
|
+
const t = $createTextNode("blue")
|
|
243
|
+
t.setStyle("color: blue")
|
|
244
|
+
p.append(t)
|
|
245
|
+
root.append(p)
|
|
246
|
+
})
|
|
247
|
+
expect(lexicalToMarkdown(editor)).toBe('plain <span style="color: blue">blue</span>')
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it("separates consecutive paragraphs with a standard blank line", () => {
|
|
251
|
+
const editor = buildEditor((root) => {
|
|
252
|
+
const a = $createParagraphNode()
|
|
253
|
+
a.append($createTextNode("abc"))
|
|
254
|
+
root.append(a)
|
|
255
|
+
const b = $createParagraphNode()
|
|
256
|
+
b.append($createTextNode("def"))
|
|
257
|
+
root.append(b)
|
|
258
|
+
})
|
|
259
|
+
// Enter produces a real paragraph break: standard Markdown `\n\n`, no marker
|
|
260
|
+
// characters in the canonical source (the user's "stray space" complaint).
|
|
261
|
+
expect(lexicalToMarkdown(editor)).toBe("abc\n\ndef")
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
it("keeps a blank line between a paragraph and a heading", () => {
|
|
265
|
+
const editor = buildEditor((root) => {
|
|
266
|
+
const p = $createParagraphNode()
|
|
267
|
+
p.append($createTextNode("intro"))
|
|
268
|
+
root.append(p)
|
|
269
|
+
const h = $createHeadingNode("h2")
|
|
270
|
+
h.append($createTextNode("Sec"))
|
|
271
|
+
root.append(h)
|
|
272
|
+
})
|
|
273
|
+
expect(lexicalToMarkdown(editor)).toBe("intro\n\n## Sec")
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
it("renders an empty paragraph as a single <br> marker (preserves the blank line)", () => {
|
|
277
|
+
const editor = buildEditor((root) => {
|
|
278
|
+
const a = $createParagraphNode()
|
|
279
|
+
a.append($createTextNode("abc"))
|
|
280
|
+
root.append(a)
|
|
281
|
+
root.append($createParagraphNode()) // user pressed Enter on an empty line
|
|
282
|
+
const b = $createParagraphNode()
|
|
283
|
+
b.append($createTextNode("def"))
|
|
284
|
+
root.append(b)
|
|
285
|
+
})
|
|
286
|
+
// The blank line the user typed is preserved as a semantic <br> marker (not a
|
|
287
|
+
// stray space, and not collapsed away): standard paragraph separation around
|
|
288
|
+
// a single <br>. The <br> renders as a visible blank line and round-trips.
|
|
289
|
+
expect(lexicalToMarkdown(editor)).toBe("abc\n\n<br>\n\ndef")
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
it("preserves multiple consecutive blank lines, one <br> per line", () => {
|
|
293
|
+
const editor = buildEditor((root) => {
|
|
294
|
+
const a = $createParagraphNode()
|
|
295
|
+
a.append($createTextNode("abc"))
|
|
296
|
+
root.append(a)
|
|
297
|
+
root.append($createParagraphNode())
|
|
298
|
+
root.append($createParagraphNode())
|
|
299
|
+
const b = $createParagraphNode()
|
|
300
|
+
b.append($createTextNode("def"))
|
|
301
|
+
root.append(b)
|
|
302
|
+
})
|
|
303
|
+
// Two empty paragraphs (two Enters) keep their exact count as two <br>
|
|
304
|
+
// markers \u2014 the standard-paragraph model collapsed these to one blank line.
|
|
305
|
+
expect(lexicalToMarkdown(editor)).toBe("abc\n\n<br>\n\n<br>\n\ndef")
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
it("serializes a blank paragraph of N line breaks as N <br> markers", () => {
|
|
309
|
+
// On reopen the importer groups N consecutive <br> elements into ONE
|
|
310
|
+
// paragraph holding N LineBreakNodes. Export must emit exactly N markers
|
|
311
|
+
// (not N+1) or blank lines multiply on every save.
|
|
312
|
+
const editor = buildEditor((root) => {
|
|
313
|
+
const a = $createParagraphNode()
|
|
314
|
+
a.append($createTextNode("abc"))
|
|
315
|
+
root.append(a)
|
|
316
|
+
const blanks = $createParagraphNode()
|
|
317
|
+
blanks.append($createLineBreakNode())
|
|
318
|
+
blanks.append($createLineBreakNode())
|
|
319
|
+
root.append(blanks)
|
|
320
|
+
const b = $createParagraphNode()
|
|
321
|
+
b.append($createTextNode("def"))
|
|
322
|
+
root.append(b)
|
|
323
|
+
})
|
|
324
|
+
expect(lexicalToMarkdown(editor)).toBe("abc\n\n<br>\n\n<br>\n\ndef")
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
it("serializes an editor that holds only empty paragraphs as empty Markdown", () => {
|
|
328
|
+
const editor = buildEditor((root) => {
|
|
329
|
+
root.append($createParagraphNode())
|
|
330
|
+
root.append($createParagraphNode())
|
|
331
|
+
})
|
|
332
|
+
// A document with no real content stays empty (no stray nbsp), matching the
|
|
333
|
+
// pre-existing empty-state contract so placeholders/presence checks hold.
|
|
334
|
+
expect(lexicalToMarkdown(editor)).toBe("")
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
it("separates plain paragraphs with a blank line and keeps an empty one as <br>", () => {
|
|
338
|
+
const editor = buildEditor((root) => {
|
|
339
|
+
const a = $createParagraphNode()
|
|
340
|
+
a.append($createTextNode("abc"))
|
|
341
|
+
root.append(a)
|
|
342
|
+
const b = $createParagraphNode()
|
|
343
|
+
b.append($createTextNode("def"))
|
|
344
|
+
root.append(b)
|
|
345
|
+
root.append($createParagraphNode())
|
|
346
|
+
const c = $createParagraphNode()
|
|
347
|
+
c.append($createTextNode("ghi"))
|
|
348
|
+
root.append(c)
|
|
349
|
+
})
|
|
350
|
+
// abc/def are adjacent paragraphs (standard \n\n separation); the empty
|
|
351
|
+
// paragraph before ghi is a deliberate blank line, kept as a <br>.
|
|
352
|
+
expect(lexicalToMarkdown(editor)).toBe("abc\n\ndef\n\n<br>\n\nghi")
|
|
353
|
+
})
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
describe("normalizeMarkdownBlankLines", () => {
|
|
357
|
+
it("keeps the standard blank line between two plain paragraphs", () => {
|
|
358
|
+
expect(normalizeMarkdownBlankLines("a\n\nb")).toBe("a\n\nb")
|
|
359
|
+
expect(normalizeMarkdownBlankLines("a\n\nb\n\nc")).toBe("a\n\nb\n\nc")
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
it("collapses runs of 3+ newlines to one standard blank line", () => {
|
|
363
|
+
expect(normalizeMarkdownBlankLines("a\n\n\nb")).toBe("a\n\nb")
|
|
364
|
+
expect(normalizeMarkdownBlankLines("a\n\n\n\n\nb")).toBe("a\n\nb")
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
it("returns empty string for blank-only input (empty-state contract)", () => {
|
|
368
|
+
expect(normalizeMarkdownBlankLines("")).toBe("")
|
|
369
|
+
expect(normalizeMarkdownBlankLines("\n")).toBe("")
|
|
370
|
+
expect(normalizeMarkdownBlankLines("\n\n \n")).toBe("")
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
it("treats a document of only <br> markers as empty (empty-state contract)", () => {
|
|
374
|
+
// A creative the user filled with nothing but blank lines carries no real
|
|
375
|
+
// content, so it stays empty (placeholders/presence checks unchanged).
|
|
376
|
+
expect(normalizeMarkdownBlankLines("<br>")).toBe("")
|
|
377
|
+
expect(normalizeMarkdownBlankLines("<br>\n\n<br>")).toBe("")
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
it("trims trailing whitespace", () => {
|
|
381
|
+
expect(normalizeMarkdownBlankLines("abc\n\n\n")).toBe("abc")
|
|
382
|
+
expect(normalizeMarkdownBlankLines("abc \n")).toBe("abc")
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
it("never collapses blank lines inside a fenced code block", () => {
|
|
386
|
+
const md = "```js\nconst a = 1\n\n\nconst b = 2\n```"
|
|
387
|
+
expect(normalizeMarkdownBlankLines(md)).toBe(md)
|
|
388
|
+
// ...and still normalizes blank-line runs around the protected fence
|
|
389
|
+
expect(normalizeMarkdownBlankLines("a\n\n\nb\n\n```\nx\n\n\ny\n```")).toBe(
|
|
390
|
+
"a\n\nb\n\n```\nx\n\n\ny\n```"
|
|
391
|
+
)
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
it("never rewrites a literal <br> inside an inline code span", () => {
|
|
395
|
+
// The rich editor exports inline code whose text is `<br>` as `` `<br>` ``.
|
|
396
|
+
// The blank-line normalizer must not treat that as a marker and inject
|
|
397
|
+
// blank lines, which would break the code span on save.
|
|
398
|
+
expect(normalizeMarkdownBlankLines("Here is `<br>` literal code")).toBe(
|
|
399
|
+
"Here is `<br>` literal code"
|
|
400
|
+
)
|
|
401
|
+
expect(normalizeMarkdownBlankLines("a `<br>` b")).toBe("a `<br>` b")
|
|
402
|
+
expect(normalizeMarkdownBlankLines("`<br>`")).toBe("`<br>`")
|
|
403
|
+
// A real marker on its own line is still isolated even alongside a code span
|
|
404
|
+
expect(normalizeMarkdownBlankLines("a `<br>` b\n<br>\nc")).toBe(
|
|
405
|
+
"a `<br>` b\n\n<br>\n\nc"
|
|
406
|
+
)
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
it("only isolates a <br> that is the whole blank-line marker line", () => {
|
|
410
|
+
// A blank-line marker is always emitted alone on its line (see
|
|
411
|
+
// BLANK_PARAGRAPH_TRANSFORMER). A <br> embedded inside a line of other
|
|
412
|
+
// content is an in-paragraph hard break / raw HTML, NOT a marker, so the
|
|
413
|
+
// normalizer must leave it (and its surroundings) untouched — injecting
|
|
414
|
+
// blank lines around it would rewrite user-authored HTML on save.
|
|
415
|
+
expect(normalizeMarkdownBlankLines("<span>foo<br>bar</span>")).toBe(
|
|
416
|
+
"<span>foo<br>bar</span>"
|
|
417
|
+
)
|
|
418
|
+
expect(normalizeMarkdownBlankLines("a<br>b")).toBe("a<br>b")
|
|
419
|
+
expect(normalizeMarkdownBlankLines("foo <br> bar")).toBe("foo <br> bar")
|
|
420
|
+
// A standalone marker line embedded next to inline-<br> text: only the
|
|
421
|
+
// whole-line marker is isolated; the inline one is preserved verbatim.
|
|
422
|
+
expect(normalizeMarkdownBlankLines("a<br>b\n<br>\nc<br>d")).toBe(
|
|
423
|
+
"a<br>b\n\n<br>\n\nc<br>d"
|
|
424
|
+
)
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
it("migrates legacy NBSP blank-line markers to <br> on save", () => {
|
|
428
|
+
// Pre-<br> content stored blank lines as a line of only U+00A0. Reopening
|
|
429
|
+
// and saving must clean it to a real <br> marker so CommonMark stops
|
|
430
|
+
// treating the following block as a lazy list continuation.
|
|
431
|
+
expect(normalizeMarkdownBlankLines("abc\n \ndef")).toBe(
|
|
432
|
+
"abc\n\n<br>\n\ndef"
|
|
433
|
+
)
|
|
434
|
+
// ...and a NBSP marker right after a list is isolated as its own block
|
|
435
|
+
expect(
|
|
436
|
+
normalizeMarkdownBlankLines("- a\n- b\n \nXXX")
|
|
437
|
+
).toBe("- a\n- b\n\n<br>\n\nXXX")
|
|
438
|
+
// A NBSP inside inline code is left untouched
|
|
439
|
+
expect(normalizeMarkdownBlankLines("see ` ` here")).toBe(
|
|
440
|
+
"see ` ` here"
|
|
441
|
+
)
|
|
442
|
+
})
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
// Reproduces the production import path: stored Markdown is rendered to HTML by
|
|
446
|
+
// the server (markdown_to_html, unsafe:true keeps the raw <span>), then imported
|
|
447
|
+
// into Lexical via $generateNodesFromDOM + the colorAwareSpanImport config. Then
|
|
448
|
+
// we re-serialize to Markdown and require byte-identity with the rendered HTML's
|
|
449
|
+
// intended source — guarding the md -> html -> lexical -> md round-trip.
|
|
450
|
+
function importHtml(html) {
|
|
451
|
+
const editor = createEditor({
|
|
452
|
+
namespace: "test",
|
|
453
|
+
onError(error) {
|
|
454
|
+
throw error
|
|
455
|
+
},
|
|
456
|
+
nodes: [HeadingNode, QuoteNode, ListNode, ListItemNode, LinkNode, CodeNode, CodeHighlightNode],
|
|
457
|
+
html: lexicalHtmlConfig
|
|
458
|
+
})
|
|
459
|
+
editor.update(
|
|
460
|
+
() => {
|
|
461
|
+
const root = $getRoot()
|
|
462
|
+
root.clear()
|
|
463
|
+
const doc = new DOMParser().parseFromString(html, "text/html")
|
|
464
|
+
normalizeColoredContainers(doc.body)
|
|
465
|
+
const nodes = $generateNodesFromDOM(editor, doc.body)
|
|
466
|
+
// Mirror InlineLexicalEditor's import grouping: text nodes, line breaks,
|
|
467
|
+
// and inline elements can't be root children, so consecutive inline leaves
|
|
468
|
+
// are grouped back into a single paragraph. This is what merges adjacent
|
|
469
|
+
// <br> markers into ONE blank paragraph holding N LineBreakNodes — the
|
|
470
|
+
// exact structure the blank-paragraph export rule must round-trip.
|
|
471
|
+
let pending = null
|
|
472
|
+
const flush = () => {
|
|
473
|
+
if (pending) {
|
|
474
|
+
root.append(pending)
|
|
475
|
+
pending = null
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
nodes.forEach((node) => {
|
|
479
|
+
const isInlineLeaf =
|
|
480
|
+
$isTextNode(node) ||
|
|
481
|
+
$isLineBreakNode(node) ||
|
|
482
|
+
($isElementNode(node) && node.isInline())
|
|
483
|
+
if (isInlineLeaf) {
|
|
484
|
+
if (!pending) pending = $createParagraphNode()
|
|
485
|
+
pending.append(node)
|
|
486
|
+
return
|
|
487
|
+
}
|
|
488
|
+
flush()
|
|
489
|
+
root.append(node)
|
|
490
|
+
})
|
|
491
|
+
flush()
|
|
492
|
+
// Mirror InlineLexicalEditor: a grouped blank-line marker paragraph (only
|
|
493
|
+
// LineBreakNodes) is split back into N empty paragraphs so reopened blank
|
|
494
|
+
// lines match the structure fresh typing produces.
|
|
495
|
+
splitBlankLineParagraphs(root)
|
|
496
|
+
},
|
|
497
|
+
{ discrete: true }
|
|
498
|
+
)
|
|
499
|
+
return editor
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// Structure of the root's children, for asserting that blank-line markers import
|
|
503
|
+
// as EMPTY paragraphs (one visual line each) rather than LineBreakNode-bearing
|
|
504
|
+
// paragraphs (which Lexical renders with an extra trailing line).
|
|
505
|
+
function importHtmlToStructure(html) {
|
|
506
|
+
const editor = importHtml(html)
|
|
507
|
+
let summary = []
|
|
508
|
+
editor.getEditorState().read(() => {
|
|
509
|
+
summary = $getRoot()
|
|
510
|
+
.getChildren()
|
|
511
|
+
.map((child) => ({
|
|
512
|
+
type: child.getType(),
|
|
513
|
+
childCount: child.getChildrenSize ? child.getChildrenSize() : 0,
|
|
514
|
+
lineBreaks: child.getChildren
|
|
515
|
+
? child.getChildren().filter($isLineBreakNode).length
|
|
516
|
+
: 0,
|
|
517
|
+
text: child.getTextContent()
|
|
518
|
+
}))
|
|
519
|
+
})
|
|
520
|
+
return summary
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function importHtmlThenToMarkdown(html) {
|
|
524
|
+
return lexicalToMarkdown(importHtml(html))
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
describe("round-trip: rendered HTML -> Lexical -> Markdown", () => {
|
|
528
|
+
// [name, HTML as markdown_to_html would render it, expected canonical Markdown]
|
|
529
|
+
const cases = [
|
|
530
|
+
["plain paragraph", "<p>hello</p>", "hello"],
|
|
531
|
+
["heading", "<h1>Title</h1>", "# Title"],
|
|
532
|
+
["bold", "<p><strong>bold</strong></p>", "**bold**"],
|
|
533
|
+
["italic", "<p><em>nice</em></p>", "*nice*"],
|
|
534
|
+
["unordered list", "<ul><li>a</li><li>b</li></ul>", "- a\n- b"],
|
|
535
|
+
// Nested list produced by Tab indentation (markdown-canonical store).
|
|
536
|
+
["nested unordered list", "<ul><li>a<ul><li>b</li></ul></li></ul>", "- a\n - b"],
|
|
537
|
+
[
|
|
538
|
+
"colored text",
|
|
539
|
+
'<p><span style="color: rgb(255, 0, 0)">red</span></p>',
|
|
540
|
+
'<span style="color: rgb(255, 0, 0)">red</span>'
|
|
541
|
+
],
|
|
542
|
+
[
|
|
543
|
+
"background color",
|
|
544
|
+
'<p><span style="background-color: rgb(255, 255, 0)">hi</span></p>',
|
|
545
|
+
'<span style="background-color: rgb(255, 255, 0)">hi</span>'
|
|
546
|
+
],
|
|
547
|
+
[
|
|
548
|
+
"bold + color",
|
|
549
|
+
'<p><span style="color: rgb(255, 0, 0)"><strong>hot</strong></span></p>',
|
|
550
|
+
'<span style="color: rgb(255, 0, 0)">**hot**</span>'
|
|
551
|
+
],
|
|
552
|
+
[
|
|
553
|
+
"colored text with HTML metacharacters",
|
|
554
|
+
'<p><span style="color: rgb(255, 0, 0)"><tag> & x</span></p>',
|
|
555
|
+
'<span style="color: rgb(255, 0, 0)"><tag> & x</span>'
|
|
556
|
+
],
|
|
557
|
+
// Two adjacent paragraphs (no deliberate blank line between them) keep the
|
|
558
|
+
// standard `\n\n` separation and no <br> marker is introduced.
|
|
559
|
+
["two adjacent paragraphs", "<p>abc</p><p>def</p>", "abc\n\ndef"],
|
|
560
|
+
// A deliberate blank line is a <br>. markdown_to_html renders
|
|
561
|
+
// `abc\n\n<br>\n\ndef` as <p>abc</p><br><p>def</p>; the importer groups the
|
|
562
|
+
// <br> into a blank paragraph and re-export emits exactly one <br>.
|
|
563
|
+
["single blank line", "<p>abc</p>\n<br>\n<p>def</p>", "abc\n\n<br>\n\ndef"],
|
|
564
|
+
// Two consecutive <br> markers come back as ONE paragraph of two
|
|
565
|
+
// LineBreakNodes; the count must be preserved (no multiplication on re-save).
|
|
566
|
+
[
|
|
567
|
+
"two blank lines",
|
|
568
|
+
"<p>abc</p>\n<br>\n<br>\n<p>def</p>",
|
|
569
|
+
"abc\n\n<br>\n\n<br>\n\ndef"
|
|
570
|
+
],
|
|
571
|
+
// A blank line right after a list no longer bleeds into the last <li>: the
|
|
572
|
+
// list closes and the <br> + following text serialize as their own blocks.
|
|
573
|
+
[
|
|
574
|
+
"blank line after a list",
|
|
575
|
+
"<ul>\n<li>test</li>\n<li>OK</li>\n</ul>\n<br>\n<p>XXXXXXX</p>",
|
|
576
|
+
"- test\n- OK\n\n<br>\n\nXXXXXXX"
|
|
577
|
+
]
|
|
578
|
+
]
|
|
579
|
+
|
|
580
|
+
it.each(cases)("round-trips %s", (_name, html, expected) => {
|
|
581
|
+
expect(importHtmlThenToMarkdown(html)).toBe(expected)
|
|
582
|
+
})
|
|
583
|
+
})
|
|
584
|
+
|
|
585
|
+
describe("import structure: blank-line markers become empty paragraphs", () => {
|
|
586
|
+
// The bug: a single typed blank line is an EMPTY paragraph (zero children,
|
|
587
|
+
// one visual line). On reopen the server renders it as a standalone <br>,
|
|
588
|
+
// which imports as a paragraph holding a LineBreakNode — and Lexical renders
|
|
589
|
+
// that as TWO lines (the break starts a new line on top of the paragraph's
|
|
590
|
+
// own line). So the blank line grew by one on every reopen. After the fix a
|
|
591
|
+
// blank-line marker imports as an empty paragraph, matching fresh typing.
|
|
592
|
+
it("imports a single blank line as one empty paragraph (not a LineBreakNode)", () => {
|
|
593
|
+
const structure = importHtmlToStructure("<p>Test</p>\n<br>\n<p>a</p>")
|
|
594
|
+
expect(structure).toEqual([
|
|
595
|
+
{ type: "paragraph", childCount: 1, lineBreaks: 0, text: "Test" },
|
|
596
|
+
{ type: "paragraph", childCount: 0, lineBreaks: 0, text: "" },
|
|
597
|
+
{ type: "paragraph", childCount: 1, lineBreaks: 0, text: "a" }
|
|
598
|
+
])
|
|
599
|
+
})
|
|
600
|
+
|
|
601
|
+
it("imports N consecutive blank lines as N empty paragraphs", () => {
|
|
602
|
+
const structure = importHtmlToStructure("<p>abc</p>\n<br>\n<br>\n<p>def</p>")
|
|
603
|
+
expect(structure).toEqual([
|
|
604
|
+
{ type: "paragraph", childCount: 1, lineBreaks: 0, text: "abc" },
|
|
605
|
+
{ type: "paragraph", childCount: 0, lineBreaks: 0, text: "" },
|
|
606
|
+
{ type: "paragraph", childCount: 0, lineBreaks: 0, text: "" },
|
|
607
|
+
{ type: "paragraph", childCount: 1, lineBreaks: 0, text: "def" }
|
|
608
|
+
])
|
|
609
|
+
})
|
|
610
|
+
|
|
611
|
+
it("leaves an in-paragraph line break (text + <br>) untouched", () => {
|
|
612
|
+
// A paragraph that mixes text and a break is NOT a blank-line marker; it
|
|
613
|
+
// must keep its LineBreakNode so real soft breaks survive.
|
|
614
|
+
const structure = importHtmlToStructure("<p>line1<br>line2</p>")
|
|
615
|
+
expect(structure).toEqual([
|
|
616
|
+
{ type: "paragraph", childCount: 3, lineBreaks: 1, text: "line1\nline2" }
|
|
617
|
+
])
|
|
618
|
+
})
|
|
619
|
+
|
|
620
|
+
it("splitBlankLineParagraphs preserves the exported <br> count (round-trip stable)", () => {
|
|
621
|
+
// After splitting, re-export still emits exactly N markers.
|
|
622
|
+
expect(importHtmlThenToMarkdown("<p>Test</p>\n<br>\n<p>a</p>")).toBe("Test\n\n<br>\n\na")
|
|
623
|
+
expect(importHtmlThenToMarkdown("<p>abc</p>\n<br>\n<br>\n<p>def</p>")).toBe(
|
|
624
|
+
"abc\n\n<br>\n\n<br>\n\ndef"
|
|
625
|
+
)
|
|
626
|
+
})
|
|
627
|
+
})
|
|
@@ -20,7 +20,12 @@ const theme = {
|
|
|
20
20
|
paragraph: "lexical-paragraph",
|
|
21
21
|
quote: "lexical-quote",
|
|
22
22
|
heading: { h1: "lexical-heading-h1", h2: "lexical-heading-h2", h3: "lexical-heading-h3" },
|
|
23
|
-
list: {
|
|
23
|
+
list: {
|
|
24
|
+
ul: "lexical-list-ul",
|
|
25
|
+
ol: "lexical-list-ol",
|
|
26
|
+
listitem: "lexical-list-item",
|
|
27
|
+
nested: { listitem: "lexical-nested-list-item" }
|
|
28
|
+
},
|
|
24
29
|
code: "lexical-code-block",
|
|
25
30
|
link: "lexical-link",
|
|
26
31
|
text: {
|
|
@@ -147,6 +152,20 @@ describe("minimizeContentHtml", () => {
|
|
|
147
152
|
)
|
|
148
153
|
})
|
|
149
154
|
|
|
155
|
+
test("nested list (Tab indent) preserves the nested <ul> structure", () => {
|
|
156
|
+
const out = minimize(serialize("<ul><li>a<ul><li>b</li></ul></li></ul>"))
|
|
157
|
+
// The wrapper <li> carries `lexical-nested-list-item` so CSS can suppress
|
|
158
|
+
// its (empty) bullet — otherwise a stray marker renders above the sub-list.
|
|
159
|
+
expect(out).toBe(
|
|
160
|
+
'<ul class="lexical-list-ul"><li value="1" class="lexical-list-item">a</li>' +
|
|
161
|
+
'<li value="2" class="lexical-list-item lexical-nested-list-item">' +
|
|
162
|
+
'<ul class="lexical-list-ul"><li value="1" class="lexical-list-item">b</li></ul>' +
|
|
163
|
+
"</li></ul>"
|
|
164
|
+
)
|
|
165
|
+
// Reopen path: re-importing the minimized HTML must be byte-stable.
|
|
166
|
+
expect(minimize(reimportFormats(out))).toBe(out)
|
|
167
|
+
})
|
|
168
|
+
|
|
150
169
|
test("link keeps href + class, drops inner span", () => {
|
|
151
170
|
const out = minimize(serialize('<p><a href="https://a.com">x</a></p>'))
|
|
152
171
|
expect(out).toContain('href="https://a.com"')
|