collavre 0.23.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/app/assets/images/collavre/landing/interface-en-dark.png +0 -0
- data/app/assets/images/collavre/landing/interface-en.png +0 -0
- data/app/assets/images/collavre/landing/interface-ko-dark.png +0 -0
- data/app/assets/images/collavre/landing/interface-ko.png +0 -0
- data/app/assets/stylesheets/collavre/comments_popup.css +40 -0
- data/app/assets/stylesheets/collavre/landing.css +92 -0
- data/app/assets/stylesheets/collavre/popup.css +4 -2
- data/app/controllers/collavre/admin/settings_controller.rb +52 -67
- data/app/controllers/collavre/api/v1/agents_controller.rb +17 -188
- data/app/controllers/collavre/api/v1/base_controller.rb +9 -27
- data/app/controllers/collavre/attachments_controller.rb +13 -3
- data/app/controllers/collavre/concerns/slide_viewable.rb +8 -32
- data/app/controllers/collavre/creatives_controller.rb +32 -99
- data/app/controllers/collavre/inbox_items_controller.rb +10 -4
- data/app/controllers/collavre/landing_controller.rb +5 -2
- data/app/controllers/collavre/topics_controller.rb +5 -0
- data/app/controllers/concerns/collavre/api/authenticatable.rb +40 -0
- data/app/errors/collavre/api_error.rb +20 -0
- data/app/helpers/collavre/creatives_helper.rb +30 -8
- data/app/javascript/components/InlineLexicalEditor.jsx +2 -34
- data/app/javascript/components/__tests__/creative_tree_row_parent_id.test.js +87 -0
- data/app/javascript/components/creative_tree_row.js +8 -2
- data/app/javascript/components/plugins/markdown_shortcuts_plugin.jsx +15 -4
- data/app/javascript/controllers/__tests__/search_popup_controller.test.js +104 -0
- data/app/javascript/controllers/__tests__/topic_list_controller.test.js +82 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_topic_list.test.js +61 -0
- data/app/javascript/controllers/comments/topics_controller.js +47 -0
- data/app/javascript/controllers/common_popup_controller.js +2 -2
- data/app/javascript/controllers/index.js +3 -0
- data/app/javascript/controllers/search_popup_controller.js +17 -1
- data/app/javascript/controllers/topic_list_controller.js +78 -0
- data/app/javascript/lib/__tests__/common_popup_bounds.test.js +58 -0
- data/app/javascript/lib/common_popup.js +24 -5
- data/app/javascript/lib/lexical/__tests__/code_block_toggle.test.js +326 -0
- data/app/javascript/lib/lexical/__tests__/code_fence_shortcut.test.js +179 -0
- data/app/javascript/lib/lexical/code_block_toggle.js +130 -0
- data/app/javascript/lib/lexical/code_fence_shortcut.js +60 -0
- data/app/javascript/modules/__tests__/creative_inline_payload.test.js +231 -0
- data/app/javascript/modules/__tests__/creative_save_queue.test.js +228 -0
- data/app/javascript/modules/__tests__/creative_tree_dom.test.js +425 -0
- data/app/javascript/modules/creative_inline_payload.js +86 -0
- data/app/javascript/modules/creative_row_editor.js +121 -401
- data/app/javascript/modules/creative_row_editor_helpers.js +76 -0
- data/app/javascript/modules/creative_save_queue.js +95 -0
- data/app/javascript/modules/creative_tree_dom.js +260 -0
- data/app/jobs/collavre/permission_cache_job.rb +20 -2
- data/app/jobs/collavre/trigger_loop_check_job.rb +20 -3
- data/app/jobs/collavre/update_mcp_tools_job.rb +17 -0
- data/app/models/collavre/channel.rb +16 -0
- data/app/models/collavre/comment/approvable.rb +16 -0
- data/app/models/collavre/comment/notifiable.rb +1 -1
- data/app/models/collavre/comment.rb +8 -1
- data/app/models/collavre/comment_version.rb +1 -1
- data/app/models/collavre/creative/describable.rb +15 -9
- data/app/models/collavre/creative/linkable.rb +9 -0
- data/app/models/collavre/creative/permissible.rb +97 -49
- data/app/models/collavre/creative.rb +73 -5
- data/app/models/collavre/creative_share.rb +78 -26
- data/app/models/collavre/inbox_item.rb +2 -2
- data/app/models/collavre/task.rb +12 -0
- data/app/models/collavre/user.rb +33 -9
- data/app/models/concerns/collavre/indexed_json_columns.rb +58 -0
- data/app/services/collavre/agent_type_classifier.rb +23 -0
- data/app/services/collavre/ai_agent/a2a_dispatcher.rb +4 -0
- data/app/services/collavre/ai_agent/message_builder.rb +1 -1
- data/app/services/collavre/ai_agent/session_provisioner.rb +126 -0
- data/app/services/collavre/ai_agent/task_claim_service.rb +96 -0
- data/app/services/collavre/ai_client.rb +53 -2
- data/app/services/collavre/auto_theme_generator.rb +14 -10
- data/app/services/collavre/creatives/children_index.rb +86 -0
- data/app/services/collavre/creatives/comment_badge_index.rb +73 -0
- data/app/services/collavre/creatives/creative_tree_serializer.rb +122 -0
- data/app/services/collavre/creatives/effective_creative_resolution.rb +37 -0
- data/app/services/collavre/creatives/permission_checker.rb +1 -1
- data/app/services/collavre/creatives/permission_filter.rb +144 -17
- data/app/services/collavre/creatives/tree_builder.rb +105 -77
- data/app/services/collavre/http_client.rb +119 -0
- data/app/services/collavre/link_preview_fetcher.rb +129 -38
- data/app/services/collavre/markdown_importer.rb +30 -6
- data/app/services/collavre/mobile/event_summarizer.rb +1 -1
- data/app/services/collavre/orchestration/agent_context_builder.rb +1 -15
- data/app/services/collavre/orchestration/agent_orchestrator.rb +1 -1
- data/app/services/collavre/system_events/context_builder.rb +1 -14
- data/app/services/collavre/tools/preview_attach_service.rb +5 -7
- data/app/views/collavre/comments/_comment.html.erb +4 -4
- data/app/views/collavre/comments/_comments_popup.html.erb +11 -3
- data/app/views/collavre/creatives/_inline_edit_form.html.erb +9 -0
- data/app/views/collavre/landing/show.html.erb +52 -10
- data/config/locales/comments.en.yml +1 -0
- data/config/locales/comments.ko.yml +1 -0
- data/config/locales/creatives.en.yml +6 -0
- data/config/locales/creatives.ko.yml +5 -0
- data/config/locales/landing.en.yml +22 -10
- data/config/locales/landing.ko.yml +22 -10
- data/db/migrate/20260120045354_encrypt_oauth_tokens.rb +13 -4
- data/db/migrate/20260702000001_enforce_system_settings_key_not_null.rb +9 -0
- data/db/migrate/20260702000002_add_creative_index_and_fk_to_tags.rb +11 -0
- data/db/migrate/20260702000003_add_creative_type_index_to_labels.rb +10 -0
- data/db/migrate/20260702000004_add_creative_created_at_index_to_comments.rb +10 -0
- data/db/migrate/20260702000005_promote_channel_config_index_columns.rb +76 -0
- data/db/migrate/20260713000001_add_comments_count_to_creatives.rb +20 -0
- data/db/migrate/20260713000002_add_comment_versions_count_to_comments.rb +20 -0
- data/db/migrate/20260715000000_change_creatives_description_to_plain_text.rb +16 -0
- data/lib/collavre/version.rb +1 -1
- metadata +42 -1
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createEditor,
|
|
3
|
+
$getRoot,
|
|
4
|
+
$createParagraphNode,
|
|
5
|
+
$createTextNode,
|
|
6
|
+
$createRangeSelection,
|
|
7
|
+
$setSelection,
|
|
8
|
+
DecoratorNode
|
|
9
|
+
} from "lexical"
|
|
10
|
+
|
|
11
|
+
// Minimal top-level decorator (stands in for ImageNode/VideoNode/AttachmentNode)
|
|
12
|
+
// so we can assert the toggle never removes media blocks. Its text content is
|
|
13
|
+
// empty, exactly like real media nodes.
|
|
14
|
+
class TestMediaNode extends DecoratorNode {
|
|
15
|
+
static getType() {
|
|
16
|
+
return "test-media"
|
|
17
|
+
}
|
|
18
|
+
static clone(node) {
|
|
19
|
+
return new TestMediaNode(node.__key)
|
|
20
|
+
}
|
|
21
|
+
createDOM() {
|
|
22
|
+
return document.createElement("div")
|
|
23
|
+
}
|
|
24
|
+
updateDOM() {
|
|
25
|
+
return false
|
|
26
|
+
}
|
|
27
|
+
decorate() {
|
|
28
|
+
return null
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
import { HeadingNode, QuoteNode, registerRichText } from "@lexical/rich-text"
|
|
32
|
+
import { CodeNode, CodeHighlightNode, $createCodeNode, $isCodeNode } from "@lexical/code"
|
|
33
|
+
import {
|
|
34
|
+
ListNode,
|
|
35
|
+
ListItemNode,
|
|
36
|
+
$createListNode,
|
|
37
|
+
$createListItemNode,
|
|
38
|
+
$isListNode
|
|
39
|
+
} from "@lexical/list"
|
|
40
|
+
import {
|
|
41
|
+
TableNode,
|
|
42
|
+
TableRowNode,
|
|
43
|
+
TableCellNode,
|
|
44
|
+
$createTableNodeWithDimensions,
|
|
45
|
+
$isTableNode
|
|
46
|
+
} from "@lexical/table"
|
|
47
|
+
import { $toggleCodeBlockForSelection } from "../code_block_toggle"
|
|
48
|
+
|
|
49
|
+
function buildEditor() {
|
|
50
|
+
const editor = createEditor({
|
|
51
|
+
namespace: "test",
|
|
52
|
+
onError(error) {
|
|
53
|
+
throw error
|
|
54
|
+
},
|
|
55
|
+
nodes: [
|
|
56
|
+
HeadingNode,
|
|
57
|
+
QuoteNode,
|
|
58
|
+
CodeNode,
|
|
59
|
+
CodeHighlightNode,
|
|
60
|
+
ListNode,
|
|
61
|
+
ListItemNode,
|
|
62
|
+
TableNode,
|
|
63
|
+
TableRowNode,
|
|
64
|
+
TableCellNode,
|
|
65
|
+
TestMediaNode
|
|
66
|
+
]
|
|
67
|
+
})
|
|
68
|
+
registerRichText(editor)
|
|
69
|
+
return editor
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function appendParagraph(root, text) {
|
|
73
|
+
const paragraph = $createParagraphNode()
|
|
74
|
+
paragraph.append($createTextNode(text))
|
|
75
|
+
root.append(paragraph)
|
|
76
|
+
return paragraph
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
describe("$toggleCodeBlockForSelection", () => {
|
|
80
|
+
it("merges every selected paragraph into a single code block (not just the last line)", () => {
|
|
81
|
+
const editor = buildEditor()
|
|
82
|
+
editor.update(
|
|
83
|
+
() => {
|
|
84
|
+
const root = $getRoot()
|
|
85
|
+
root.clear()
|
|
86
|
+
const a = appendParagraph(root, "line one")
|
|
87
|
+
appendParagraph(root, "line two")
|
|
88
|
+
const c = appendParagraph(root, "line three")
|
|
89
|
+
const selection = $createRangeSelection()
|
|
90
|
+
selection.anchor.set(a.getFirstChild().getKey(), 0, "text")
|
|
91
|
+
selection.focus.set(c.getFirstChild().getKey(), "line three".length, "text")
|
|
92
|
+
$setSelection(selection)
|
|
93
|
+
$toggleCodeBlockForSelection()
|
|
94
|
+
},
|
|
95
|
+
{ discrete: true }
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
editor.read(() => {
|
|
99
|
+
const children = $getRoot().getChildren()
|
|
100
|
+
expect(children).toHaveLength(1)
|
|
101
|
+
expect($isCodeNode(children[0])).toBe(true)
|
|
102
|
+
expect(children[0].getTextContent()).toBe("line one\nline two\nline three")
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it("converts a single collapsed-selection paragraph into a code block", () => {
|
|
107
|
+
const editor = buildEditor()
|
|
108
|
+
editor.update(
|
|
109
|
+
() => {
|
|
110
|
+
const root = $getRoot()
|
|
111
|
+
root.clear()
|
|
112
|
+
const p = appendParagraph(root, "hello")
|
|
113
|
+
p.getFirstChild().selectEnd()
|
|
114
|
+
$toggleCodeBlockForSelection()
|
|
115
|
+
},
|
|
116
|
+
{ discrete: true }
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
editor.read(() => {
|
|
120
|
+
const children = $getRoot().getChildren()
|
|
121
|
+
expect(children).toHaveLength(1)
|
|
122
|
+
expect($isCodeNode(children[0])).toBe(true)
|
|
123
|
+
expect(children[0].getTextContent()).toBe("hello")
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it("toggles a selected code block back into one paragraph per line", () => {
|
|
128
|
+
const editor = buildEditor()
|
|
129
|
+
editor.update(
|
|
130
|
+
() => {
|
|
131
|
+
const root = $getRoot()
|
|
132
|
+
root.clear()
|
|
133
|
+
const codeNode = $createCodeNode()
|
|
134
|
+
codeNode.append($createTextNode("a\nb"))
|
|
135
|
+
root.append(codeNode)
|
|
136
|
+
codeNode.selectEnd()
|
|
137
|
+
$toggleCodeBlockForSelection()
|
|
138
|
+
},
|
|
139
|
+
{ discrete: true }
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
editor.read(() => {
|
|
143
|
+
const children = $getRoot().getChildren()
|
|
144
|
+
expect(children.some($isCodeNode)).toBe(false)
|
|
145
|
+
expect(children.map((child) => child.getTextContent())).toEqual(["a", "b"])
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it("absorbs a mixed selection (heading + paragraph) into one code block", () => {
|
|
150
|
+
const editor = buildEditor()
|
|
151
|
+
editor.update(
|
|
152
|
+
() => {
|
|
153
|
+
const root = $getRoot()
|
|
154
|
+
root.clear()
|
|
155
|
+
const heading = new HeadingNode("h1")
|
|
156
|
+
heading.append($createTextNode("Title"))
|
|
157
|
+
root.append(heading)
|
|
158
|
+
const body = appendParagraph(root, "body")
|
|
159
|
+
const selection = $createRangeSelection()
|
|
160
|
+
selection.anchor.set(heading.getFirstChild().getKey(), 0, "text")
|
|
161
|
+
selection.focus.set(body.getFirstChild().getKey(), "body".length, "text")
|
|
162
|
+
$setSelection(selection)
|
|
163
|
+
$toggleCodeBlockForSelection()
|
|
164
|
+
},
|
|
165
|
+
{ discrete: true }
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
editor.read(() => {
|
|
169
|
+
const children = $getRoot().getChildren()
|
|
170
|
+
expect(children).toHaveLength(1)
|
|
171
|
+
expect($isCodeNode(children[0])).toBe(true)
|
|
172
|
+
expect(children[0].getTextContent()).toBe("Title\nbody")
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it("does not flatten a table when the caret is inside a table cell", () => {
|
|
177
|
+
const editor = buildEditor()
|
|
178
|
+
editor.update(
|
|
179
|
+
() => {
|
|
180
|
+
const root = $getRoot()
|
|
181
|
+
root.clear()
|
|
182
|
+
const table = $createTableNodeWithDimensions(2, 2, false)
|
|
183
|
+
root.append(table)
|
|
184
|
+
const cellParagraph = table.getFirstChild().getFirstChild().getFirstChild()
|
|
185
|
+
cellParagraph.append($createTextNode("cell"))
|
|
186
|
+
cellParagraph.getFirstChild().selectEnd()
|
|
187
|
+
$toggleCodeBlockForSelection()
|
|
188
|
+
},
|
|
189
|
+
{ discrete: true }
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
editor.read(() => {
|
|
193
|
+
const children = $getRoot().getChildren()
|
|
194
|
+
expect(children.some($isTableNode)).toBe(true)
|
|
195
|
+
expect(children.some($isCodeNode)).toBe(false)
|
|
196
|
+
})
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it("does not fold table-cell content into the merge when a table sits inside the range", () => {
|
|
200
|
+
const editor = buildEditor()
|
|
201
|
+
editor.update(
|
|
202
|
+
() => {
|
|
203
|
+
const root = $getRoot()
|
|
204
|
+
root.clear()
|
|
205
|
+
const before = appendParagraph(root, "before")
|
|
206
|
+
const table = $createTableNodeWithDimensions(2, 2, false)
|
|
207
|
+
root.append(table)
|
|
208
|
+
// Put text in the first cell so we can assert it is never merged out.
|
|
209
|
+
const cellParagraph = table.getFirstChild().getFirstChild().getFirstChild()
|
|
210
|
+
cellParagraph.clear()
|
|
211
|
+
cellParagraph.append($createTextNode("cell"))
|
|
212
|
+
const after = appendParagraph(root, "after")
|
|
213
|
+
// Endpoints are the outside paragraphs; the range walks through the
|
|
214
|
+
// table interior. getTableCell content top-levels to the cell paragraph,
|
|
215
|
+
// which must be excluded from the merge (it is table-scoped).
|
|
216
|
+
const selection = $createRangeSelection()
|
|
217
|
+
selection.anchor.set(before.getFirstChild().getKey(), 0, "text")
|
|
218
|
+
selection.focus.set(after.getFirstChild().getKey(), "after".length, "text")
|
|
219
|
+
$setSelection(selection)
|
|
220
|
+
$toggleCodeBlockForSelection()
|
|
221
|
+
},
|
|
222
|
+
{ discrete: true }
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
editor.read(() => {
|
|
226
|
+
const children = $getRoot().getChildren()
|
|
227
|
+
// Table survives in place; its cell text is NOT pulled into any code block,
|
|
228
|
+
// and "after" is not moved above the table (document order preserved).
|
|
229
|
+
expect(children.map((c) => c.getType())).toEqual(["code", "table", "code"])
|
|
230
|
+
const table = children.find($isTableNode)
|
|
231
|
+
expect(table.getTextContent()).toContain("cell")
|
|
232
|
+
const codeBlocks = children.filter($isCodeNode)
|
|
233
|
+
expect(codeBlocks.map((c) => c.getTextContent())).toEqual(["before", "after"])
|
|
234
|
+
})
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it("leaves the whole list intact when only one bullet is selected", () => {
|
|
238
|
+
const editor = buildEditor()
|
|
239
|
+
editor.update(
|
|
240
|
+
() => {
|
|
241
|
+
const root = $getRoot()
|
|
242
|
+
root.clear()
|
|
243
|
+
const list = $createListNode("bullet")
|
|
244
|
+
const first = $createListItemNode()
|
|
245
|
+
first.append($createTextNode("one"))
|
|
246
|
+
const second = $createListItemNode()
|
|
247
|
+
second.append($createTextNode("two"))
|
|
248
|
+
list.append(first, second)
|
|
249
|
+
root.append(list)
|
|
250
|
+
// Caret sits in the first bullet only.
|
|
251
|
+
first.getFirstChild().selectEnd()
|
|
252
|
+
$toggleCodeBlockForSelection()
|
|
253
|
+
},
|
|
254
|
+
{ discrete: true }
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
editor.read(() => {
|
|
258
|
+
const children = $getRoot().getChildren()
|
|
259
|
+
// The list survives whole — the unselected second bullet is not absorbed
|
|
260
|
+
// into a code block, and no code block is created from a single bullet.
|
|
261
|
+
expect(children.some($isListNode)).toBe(true)
|
|
262
|
+
expect(children.some($isCodeNode)).toBe(false)
|
|
263
|
+
expect(children.find($isListNode).getTextContent()).toContain("two")
|
|
264
|
+
})
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it("merges surrounding text but leaves a list in place (structural, order preserved)", () => {
|
|
268
|
+
const editor = buildEditor()
|
|
269
|
+
editor.update(
|
|
270
|
+
() => {
|
|
271
|
+
const root = $getRoot()
|
|
272
|
+
root.clear()
|
|
273
|
+
const before = appendParagraph(root, "before")
|
|
274
|
+
const list = $createListNode("bullet")
|
|
275
|
+
const item = $createListItemNode()
|
|
276
|
+
item.append($createTextNode("bullet"))
|
|
277
|
+
list.append(item)
|
|
278
|
+
root.append(list)
|
|
279
|
+
const after = appendParagraph(root, "after")
|
|
280
|
+
const selection = $createRangeSelection()
|
|
281
|
+
selection.anchor.set(before.getFirstChild().getKey(), 0, "text")
|
|
282
|
+
selection.focus.set(after.getFirstChild().getKey(), "after".length, "text")
|
|
283
|
+
$setSelection(selection)
|
|
284
|
+
$toggleCodeBlockForSelection()
|
|
285
|
+
},
|
|
286
|
+
{ discrete: true }
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
editor.read(() => {
|
|
290
|
+
const children = $getRoot().getChildren()
|
|
291
|
+
// List stays in place; "after" is not pulled above it (order preserved).
|
|
292
|
+
expect(children.map((c) => c.getType())).toEqual(["code", "list", "code"])
|
|
293
|
+
const codeBlocks = children.filter($isCodeNode)
|
|
294
|
+
expect(codeBlocks.map((c) => c.getTextContent())).toEqual(["before", "after"])
|
|
295
|
+
})
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
it("merges surrounding text but leaves a selected media (decorator) block intact", () => {
|
|
299
|
+
const editor = buildEditor()
|
|
300
|
+
editor.update(
|
|
301
|
+
() => {
|
|
302
|
+
const root = $getRoot()
|
|
303
|
+
root.clear()
|
|
304
|
+
const before = appendParagraph(root, "before")
|
|
305
|
+
const media = new TestMediaNode()
|
|
306
|
+
root.append(media)
|
|
307
|
+
const after = appendParagraph(root, "after")
|
|
308
|
+
const selection = $createRangeSelection()
|
|
309
|
+
selection.anchor.set(before.getFirstChild().getKey(), 0, "text")
|
|
310
|
+
selection.focus.set(after.getFirstChild().getKey(), "after".length, "text")
|
|
311
|
+
$setSelection(selection)
|
|
312
|
+
$toggleCodeBlockForSelection()
|
|
313
|
+
},
|
|
314
|
+
{ discrete: true }
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
editor.read(() => {
|
|
318
|
+
const children = $getRoot().getChildren()
|
|
319
|
+
// Media block must survive AND stay in document order. Text on each side of
|
|
320
|
+
// it becomes its own code block — "after" is never pulled above the media.
|
|
321
|
+
expect(children.map((c) => c.getType())).toEqual(["code", "test-media", "code"])
|
|
322
|
+
const codeBlocks = children.filter($isCodeNode)
|
|
323
|
+
expect(codeBlocks.map((c) => c.getTextContent())).toEqual(["before", "after"])
|
|
324
|
+
})
|
|
325
|
+
})
|
|
326
|
+
})
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createEditor,
|
|
3
|
+
$getRoot,
|
|
4
|
+
$createParagraphNode,
|
|
5
|
+
$createTextNode,
|
|
6
|
+
$createRangeSelection,
|
|
7
|
+
$setSelection,
|
|
8
|
+
KEY_ENTER_COMMAND
|
|
9
|
+
} from "lexical"
|
|
10
|
+
import { HeadingNode, QuoteNode, registerRichText } from "@lexical/rich-text"
|
|
11
|
+
import { CodeNode, CodeHighlightNode, $isCodeNode } from "@lexical/code"
|
|
12
|
+
import { registerCodeFenceShortcut } from "../code_fence_shortcut"
|
|
13
|
+
import { isLanguageResolved } from "../../editor/code_languages"
|
|
14
|
+
|
|
15
|
+
function buildEditor() {
|
|
16
|
+
const editor = createEditor({
|
|
17
|
+
namespace: "test",
|
|
18
|
+
onError(error) {
|
|
19
|
+
throw error
|
|
20
|
+
},
|
|
21
|
+
nodes: [HeadingNode, QuoteNode, CodeNode, CodeHighlightNode]
|
|
22
|
+
})
|
|
23
|
+
registerRichText(editor)
|
|
24
|
+
registerCodeFenceShortcut(editor)
|
|
25
|
+
return editor
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Put `text` into a fresh paragraph with the caret at its end — the state right
|
|
29
|
+
// before the user presses Enter. This does NOT convert on its own; conversion is
|
|
30
|
+
// the Enter keystroke, so the language token can be typed first.
|
|
31
|
+
function setFenceLine(editor, text) {
|
|
32
|
+
editor.update(
|
|
33
|
+
() => {
|
|
34
|
+
const root = $getRoot()
|
|
35
|
+
root.clear()
|
|
36
|
+
const paragraph = $createParagraphNode()
|
|
37
|
+
const textNode = $createTextNode(text)
|
|
38
|
+
paragraph.append(textNode)
|
|
39
|
+
root.append(paragraph)
|
|
40
|
+
const selection = $createRangeSelection()
|
|
41
|
+
selection.anchor.set(textNode.getKey(), text.length, "text")
|
|
42
|
+
selection.focus.set(textNode.getKey(), text.length, "text")
|
|
43
|
+
$setSelection(selection)
|
|
44
|
+
},
|
|
45
|
+
{ discrete: true }
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Dispatch a bare Enter (the fence-commit keystroke). Returns nothing; read the
|
|
50
|
+
// editor state afterwards to assert the outcome.
|
|
51
|
+
function pressEnter(editor, { shiftKey = false } = {}) {
|
|
52
|
+
editor.dispatchCommand(KEY_ENTER_COMMAND, {
|
|
53
|
+
shiftKey,
|
|
54
|
+
preventDefault() {}
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe("registerCodeFenceShortcut", () => {
|
|
59
|
+
it("does not convert until Enter — so ```lang can be typed first", () => {
|
|
60
|
+
const editor = buildEditor()
|
|
61
|
+
// The fence line is present with the caret at its end, but no Enter yet.
|
|
62
|
+
setFenceLine(editor, "```")
|
|
63
|
+
editor.read(() => {
|
|
64
|
+
expect($isCodeNode($getRoot().getFirstChild())).toBe(false)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
// Extending the fence with a language must not have converted it either.
|
|
68
|
+
setFenceLine(editor, "```json")
|
|
69
|
+
editor.read(() => {
|
|
70
|
+
expect($isCodeNode($getRoot().getFirstChild())).toBe(false)
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it("converts a bare ``` fence into an empty code block on Enter", () => {
|
|
75
|
+
const editor = buildEditor()
|
|
76
|
+
setFenceLine(editor, "```")
|
|
77
|
+
pressEnter(editor)
|
|
78
|
+
|
|
79
|
+
editor.read(() => {
|
|
80
|
+
const children = $getRoot().getChildren()
|
|
81
|
+
expect(children).toHaveLength(1)
|
|
82
|
+
expect($isCodeNode(children[0])).toBe(true)
|
|
83
|
+
expect(children[0].getTextContent()).toBe("")
|
|
84
|
+
expect(children[0].getLanguage()).toBeFalsy()
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it("carries the language token from ```json on Enter", () => {
|
|
89
|
+
const editor = buildEditor()
|
|
90
|
+
setFenceLine(editor, "```json")
|
|
91
|
+
pressEnter(editor)
|
|
92
|
+
|
|
93
|
+
editor.read(() => {
|
|
94
|
+
const codeNode = $getRoot().getFirstChild()
|
|
95
|
+
expect($isCodeNode(codeNode)).toBe(true)
|
|
96
|
+
expect(codeNode.getLanguage()).toBe("json")
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it("marks an explicit fence language resolved so highlighting won't relabel it", () => {
|
|
101
|
+
const editor = buildEditor()
|
|
102
|
+
// ```javascript is ambiguous with the tokenizer's baked default; without the
|
|
103
|
+
// resolved marker the detect transform would re-detect and could relabel it.
|
|
104
|
+
setFenceLine(editor, "```javascript")
|
|
105
|
+
pressEnter(editor)
|
|
106
|
+
|
|
107
|
+
let key
|
|
108
|
+
editor.read(() => {
|
|
109
|
+
const codeNode = $getRoot().getFirstChild()
|
|
110
|
+
expect($isCodeNode(codeNode)).toBe(true)
|
|
111
|
+
expect(codeNode.getLanguage()).toBe("javascript")
|
|
112
|
+
key = codeNode.getKey()
|
|
113
|
+
})
|
|
114
|
+
expect(isLanguageResolved(editor, key)).toBe(true)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it("does not mark a bare ``` fence resolved", () => {
|
|
118
|
+
const editor = buildEditor()
|
|
119
|
+
setFenceLine(editor, "```")
|
|
120
|
+
pressEnter(editor)
|
|
121
|
+
|
|
122
|
+
let key
|
|
123
|
+
editor.read(() => {
|
|
124
|
+
key = $getRoot().getFirstChild().getKey()
|
|
125
|
+
})
|
|
126
|
+
expect(isLanguageResolved(editor, key)).toBe(false)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it("leaves Shift+Enter as a soft newline, not a fence commit", () => {
|
|
130
|
+
const editor = buildEditor()
|
|
131
|
+
setFenceLine(editor, "```")
|
|
132
|
+
pressEnter(editor, { shiftKey: true })
|
|
133
|
+
|
|
134
|
+
editor.read(() => {
|
|
135
|
+
expect($isCodeNode($getRoot().getFirstChild())).toBe(false)
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it("leaves prose that merely contains backticks untouched on Enter", () => {
|
|
140
|
+
const editor = buildEditor()
|
|
141
|
+
setFenceLine(editor, "run ```code``` inline")
|
|
142
|
+
pressEnter(editor)
|
|
143
|
+
|
|
144
|
+
editor.read(() => {
|
|
145
|
+
const child = $getRoot().getFirstChild()
|
|
146
|
+
expect($isCodeNode(child)).toBe(false)
|
|
147
|
+
expect(child.getTextContent()).toBe("run ```code``` inline")
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it("does not convert a ``` paragraph when the caret is elsewhere", () => {
|
|
152
|
+
const editor = buildEditor()
|
|
153
|
+
editor.update(
|
|
154
|
+
() => {
|
|
155
|
+
const root = $getRoot()
|
|
156
|
+
root.clear()
|
|
157
|
+
const fence = $createParagraphNode()
|
|
158
|
+
fence.append($createTextNode("```"))
|
|
159
|
+
root.append(fence)
|
|
160
|
+
const other = $createParagraphNode()
|
|
161
|
+
const otherText = $createTextNode("elsewhere")
|
|
162
|
+
other.append(otherText)
|
|
163
|
+
root.append(other)
|
|
164
|
+
// Caret sits in the OTHER paragraph, not the fence line.
|
|
165
|
+
const selection = $createRangeSelection()
|
|
166
|
+
selection.anchor.set(otherText.getKey(), 1, "text")
|
|
167
|
+
selection.focus.set(otherText.getKey(), 1, "text")
|
|
168
|
+
$setSelection(selection)
|
|
169
|
+
},
|
|
170
|
+
{ discrete: true }
|
|
171
|
+
)
|
|
172
|
+
pressEnter(editor)
|
|
173
|
+
|
|
174
|
+
editor.read(() => {
|
|
175
|
+
const children = $getRoot().getChildren()
|
|
176
|
+
expect(children.some($isCodeNode)).toBe(false)
|
|
177
|
+
})
|
|
178
|
+
})
|
|
179
|
+
})
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import {
|
|
2
|
+
$createParagraphNode,
|
|
3
|
+
$createTextNode,
|
|
4
|
+
$getSelection,
|
|
5
|
+
$isDecoratorNode,
|
|
6
|
+
$isRangeSelection
|
|
7
|
+
} from "lexical"
|
|
8
|
+
import { $createCodeNode, $isCodeNode } from "@lexical/code"
|
|
9
|
+
import { $isTableNode } from "@lexical/table"
|
|
10
|
+
import { $isListNode } from "@lexical/list"
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Toggle the current selection between a code block and normal paragraphs.
|
|
14
|
+
*
|
|
15
|
+
* Must be called inside an editor.update() callback.
|
|
16
|
+
*
|
|
17
|
+
* Selecting several blocks and hitting the code-block button merges ALL of them
|
|
18
|
+
* into a single code block (one line per source block), instead of only the
|
|
19
|
+
* anchor block — the old single-node behaviour dropped every line but the last.
|
|
20
|
+
*
|
|
21
|
+
* When every selected top-level block is already a code block the toggle runs in
|
|
22
|
+
* reverse: each code block is expanded back into one paragraph per line.
|
|
23
|
+
* Text blocks (headings, quotes, paragraphs) are absorbed as plain text; tables,
|
|
24
|
+
* media, and lists are structural and left in place (see $isMergeableTextBlock).
|
|
25
|
+
*/
|
|
26
|
+
export function $toggleCodeBlockForSelection() {
|
|
27
|
+
const selection = $getSelection()
|
|
28
|
+
if (!$isRangeSelection(selection)) return
|
|
29
|
+
|
|
30
|
+
const topLevels = $selectedTopLevelBlocks(selection)
|
|
31
|
+
if (topLevels.length === 0) return
|
|
32
|
+
|
|
33
|
+
// All selected blocks are code → toggle OFF (expand each back to paragraphs).
|
|
34
|
+
if (topLevels.every($isCodeNode)) {
|
|
35
|
+
let lastParagraph = null
|
|
36
|
+
topLevels.forEach((codeNode) => {
|
|
37
|
+
lastParagraph = $expandCodeNodeToParagraphs(codeNode)
|
|
38
|
+
})
|
|
39
|
+
// The code nodes the selection referenced are gone; move the caret to the
|
|
40
|
+
// end of the expanded text so Lexical keeps a valid selection.
|
|
41
|
+
if (lastParagraph) lastParagraph.selectEnd()
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Otherwise → fold the selected TEXT blocks into code blocks. Tables and media
|
|
46
|
+
// (image/video/attachment DecoratorNodes) are structural: their text content is
|
|
47
|
+
// empty or a flattening of tabular data, so absorbing them would silently
|
|
48
|
+
// destroy the table/media. They are left in place, which SPLITS the selection:
|
|
49
|
+
// merging text from both sides of a structural block into one code block would
|
|
50
|
+
// pull later content across the block and corrupt document order. So each
|
|
51
|
+
// contiguous run of text blocks becomes its own code block, structural blocks
|
|
52
|
+
// untouched in between (matches the old paragraph-only guard, widened to
|
|
53
|
+
// headings/quotes/lists but not across structural containers).
|
|
54
|
+
let run = []
|
|
55
|
+
let lastCode = null
|
|
56
|
+
const flushRun = () => {
|
|
57
|
+
if (run.length === 0) return
|
|
58
|
+
const content = run.map((node) => node.getTextContent()).join("\n")
|
|
59
|
+
const codeNode = $createCodeNode()
|
|
60
|
+
codeNode.append($createTextNode(content))
|
|
61
|
+
run[0].replace(codeNode)
|
|
62
|
+
for (let index = 1; index < run.length; index += 1) run[index].remove()
|
|
63
|
+
lastCode = codeNode
|
|
64
|
+
run = []
|
|
65
|
+
}
|
|
66
|
+
topLevels.forEach((node) => {
|
|
67
|
+
if ($isMergeableTextBlock(node)) run.push(node)
|
|
68
|
+
else flushRun()
|
|
69
|
+
})
|
|
70
|
+
flushRun()
|
|
71
|
+
if (lastCode) lastCode.selectEnd()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// A block can be folded into a code block only if its text content faithfully
|
|
75
|
+
// represents it. Tables (tabular structure) and decorator media (no text) do
|
|
76
|
+
// not, so they are excluded from the merge to avoid destroying content.
|
|
77
|
+
function $isMergeableTextBlock(node) {
|
|
78
|
+
if ($isTableNode(node) || $isDecoratorNode(node)) return false
|
|
79
|
+
// A ListNode is the top-level element for every bullet, so selecting text in
|
|
80
|
+
// ONE bullet top-levels to the whole list. Merging it would flatten every
|
|
81
|
+
// sibling bullet (via getTextContent()) and replace the entire list — losing
|
|
82
|
+
// unselected bullets. Item-granular folding (split the list, keep the rest) is
|
|
83
|
+
// out of scope here, so lists are treated as structural and left in place;
|
|
84
|
+
// convert a list to code via a ``` fence instead.
|
|
85
|
+
if ($isListNode(node)) return false
|
|
86
|
+
// TableCellNode is a shadow root, so getTopLevelElement() on cell content
|
|
87
|
+
// returns the cell's own paragraph — a plain block that slips past the
|
|
88
|
+
// $isTableNode check above. Merging that cell block with document-root blocks
|
|
89
|
+
// moves content across the table boundary (relocating outside text into a
|
|
90
|
+
// cell, or replacing the cell body). Never merge table-scoped blocks.
|
|
91
|
+
if (node.getParents().some($isTableNode)) return false
|
|
92
|
+
return true
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Distinct top-level blocks the selection touches, in document order. getNodes()
|
|
96
|
+
// returns the range's nodes in document order; each maps to its owning top-level
|
|
97
|
+
// element. Falls back to the anchor's block for a collapsed selection.
|
|
98
|
+
function $selectedTopLevelBlocks(selection) {
|
|
99
|
+
const seen = new Set()
|
|
100
|
+
const blocks = []
|
|
101
|
+
selection.getNodes().forEach((node) => {
|
|
102
|
+
const topLevel = node.getTopLevelElement()
|
|
103
|
+
if (!topLevel || seen.has(topLevel.getKey())) return
|
|
104
|
+
seen.add(topLevel.getKey())
|
|
105
|
+
blocks.push(topLevel)
|
|
106
|
+
})
|
|
107
|
+
if (blocks.length === 0) {
|
|
108
|
+
const anchorTop = selection.anchor.getNode().getTopLevelElement()
|
|
109
|
+
if (anchorTop) blocks.push(anchorTop)
|
|
110
|
+
}
|
|
111
|
+
return blocks
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Replace a code block with one paragraph per line, preserving order. Returns
|
|
115
|
+
// the last paragraph so the caller can restore a valid selection (the code
|
|
116
|
+
// node the selection pointed at is gone once we replace it).
|
|
117
|
+
function $expandCodeNodeToParagraphs(codeNode) {
|
|
118
|
+
const lines = codeNode.getTextContent().split("\n")
|
|
119
|
+
const firstParagraph = $createParagraphNode()
|
|
120
|
+
firstParagraph.append($createTextNode(lines[0] || ""))
|
|
121
|
+
codeNode.replace(firstParagraph)
|
|
122
|
+
let previous = firstParagraph
|
|
123
|
+
for (let index = 1; index < lines.length; index += 1) {
|
|
124
|
+
const paragraph = $createParagraphNode()
|
|
125
|
+
paragraph.append($createTextNode(lines[index]))
|
|
126
|
+
previous.insertAfter(paragraph)
|
|
127
|
+
previous = paragraph
|
|
128
|
+
}
|
|
129
|
+
return previous
|
|
130
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import {
|
|
2
|
+
$getSelection,
|
|
3
|
+
$isRangeSelection,
|
|
4
|
+
$isParagraphNode,
|
|
5
|
+
KEY_ENTER_COMMAND,
|
|
6
|
+
COMMAND_PRIORITY_HIGH
|
|
7
|
+
} from "lexical"
|
|
8
|
+
import { $createCodeNode } from "@lexical/code"
|
|
9
|
+
import { markLanguageResolved } from "../editor/code_languages"
|
|
10
|
+
|
|
11
|
+
// A paragraph whose entire text is a Markdown fence opener: three backticks,
|
|
12
|
+
// optionally followed by a language token (e.g. ```ruby). Nothing may follow
|
|
13
|
+
// the language, so normal prose that merely contains backticks is untouched.
|
|
14
|
+
const FENCE_REGEX = /^```([\w+-]*)$/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Open a code block when the user presses Enter on a "```" (optionally
|
|
18
|
+
* "```lang") line. The built-in @lexical/markdown CODE transformer only fires on
|
|
19
|
+
* "``` " + a trailing space, so Enter after the fence (what users actually do)
|
|
20
|
+
* left the paragraph as plain text; this adds the Enter path.
|
|
21
|
+
*
|
|
22
|
+
* Enter is used deliberately rather than reacting to the fence text itself:
|
|
23
|
+
* converting the moment the third backtick lands would swallow "```json" before
|
|
24
|
+
* the language could be typed. Conversion must wait for the delimiter — a space
|
|
25
|
+
* (handled by the built-in transformer) or Enter (handled here) — so whatever
|
|
26
|
+
* language the user typed between the fence and the delimiter is captured.
|
|
27
|
+
*
|
|
28
|
+
* Returns the editor.registerCommand teardown so callers can clean up.
|
|
29
|
+
*/
|
|
30
|
+
export function registerCodeFenceShortcut(editor) {
|
|
31
|
+
return editor.registerCommand(
|
|
32
|
+
KEY_ENTER_COMMAND,
|
|
33
|
+
(event) => {
|
|
34
|
+
// Shift+Enter is a soft newline within a block, never a fence commit.
|
|
35
|
+
if (event?.shiftKey) return false
|
|
36
|
+
|
|
37
|
+
const selection = $getSelection()
|
|
38
|
+
if (!$isRangeSelection(selection) || !selection.isCollapsed()) return false
|
|
39
|
+
|
|
40
|
+
const paragraph = selection.anchor.getNode().getTopLevelElement()
|
|
41
|
+
if (!$isParagraphNode(paragraph)) return false
|
|
42
|
+
|
|
43
|
+
const match = paragraph.getTextContent().match(FENCE_REGEX)
|
|
44
|
+
if (!match) return false
|
|
45
|
+
|
|
46
|
+
// Swallow the newline Enter would otherwise insert; replace the fence line.
|
|
47
|
+
event?.preventDefault()
|
|
48
|
+
const language = match[1] || undefined
|
|
49
|
+
const codeNode = $createCodeNode(language)
|
|
50
|
+
paragraph.replace(codeNode)
|
|
51
|
+
// An explicit fence language must survive the highlight transform, which
|
|
52
|
+
// otherwise re-detects any block still on the "javascript" default and would
|
|
53
|
+
// silently relabel a deliberate ```javascript. Mirror the import path.
|
|
54
|
+
if (language) markLanguageResolved(editor, codeNode.getKey())
|
|
55
|
+
codeNode.selectStart()
|
|
56
|
+
return true
|
|
57
|
+
},
|
|
58
|
+
COMMAND_PRIORITY_HIGH
|
|
59
|
+
)
|
|
60
|
+
}
|