collavre 0.22.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/README.md +1 -0
- 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/actiontext.css +251 -90
- data/app/assets/stylesheets/collavre/code_highlight.css +7 -201
- data/app/assets/stylesheets/collavre/comments_popup.css +158 -61
- data/app/assets/stylesheets/collavre/creatives.css +11 -2
- data/app/assets/stylesheets/collavre/landing.css +92 -0
- data/app/assets/stylesheets/collavre/modal_dialog.css +32 -0
- data/app/assets/stylesheets/collavre/popup.css +4 -2
- data/app/assets/stylesheets/collavre/tables.css +91 -0
- data/app/channels/collavre/inbox_badge_channel.rb +30 -0
- 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/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/attachments_controller.rb +13 -3
- data/app/controllers/collavre/concerns/slide_viewable.rb +8 -32
- data/app/controllers/collavre/creatives_controller.rb +47 -103
- data/app/controllers/collavre/inbox_items_controller.rb +10 -4
- data/app/controllers/collavre/landing_controller.rb +5 -2
- data/app/controllers/collavre/tasks_controller.rb +13 -4
- data/app/controllers/collavre/topics_controller.rb +54 -1
- data/app/controllers/collavre/typo_corrections_controller.rb +39 -0
- data/app/controllers/concerns/collavre/api/authenticatable.rb +40 -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/errors/collavre/api_error.rb +20 -0
- data/app/helpers/collavre/application_helper.rb +1 -0
- data/app/helpers/collavre/creatives_helper.rb +30 -8
- data/app/javascript/collavre.js +2 -0
- data/app/javascript/components/ImageResizer.jsx +9 -3
- data/app/javascript/components/InlineLexicalEditor.jsx +157 -72
- data/app/javascript/components/__tests__/creative_tree_row_parent_id.test.js +87 -0
- data/app/javascript/components/creative_tree_row.js +28 -5
- data/app/javascript/components/plugins/list_tab_indent_plugin.jsx +16 -0
- data/app/javascript/components/plugins/markdown_shortcuts_plugin.jsx +15 -4
- 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/__tests__/search_popup_controller.test.js +104 -0
- data/app/javascript/controllers/__tests__/topic_list_controller.test.js +82 -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/__tests__/topics_controller_topic_list.test.js +61 -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 +61 -8
- data/app/javascript/controllers/common_popup_controller.js +2 -2
- 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 +7 -1
- data/app/javascript/controllers/search_popup_controller.js +17 -1
- data/app/javascript/controllers/share_modal_controller.js +4 -3
- data/app/javascript/controllers/topic_list_controller.js +78 -0
- 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__/common_popup_bounds.test.js +58 -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 +42 -10
- 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__/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/__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/code_block_toggle.js +130 -0
- data/app/javascript/lib/lexical/code_fence_shortcut.js +60 -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__/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/__tests__/typo_corrector.test.js +365 -0
- data/app/javascript/modules/creative_inline_payload.js +86 -0
- data/app/javascript/modules/creative_row_editor.js +231 -471
- 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/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/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/broadcastable.rb +46 -7
- data/app/models/collavre/comment/notifiable.rb +15 -5
- data/app/models/collavre/comment.rb +87 -32
- data/app/models/collavre/comment_version.rb +1 -1
- data/app/models/collavre/creative/describable.rb +104 -19
- 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 +27 -0
- data/app/models/collavre/user.rb +90 -10
- 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 +81 -12
- data/app/services/collavre/auto_theme_generator.rb +15 -11
- 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/index_query.rb +85 -16
- 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 +107 -78
- data/app/services/collavre/gemini_parent_recommender.rb +1 -1
- data/app/services/collavre/http_client.rb +119 -0
- data/app/services/collavre/inbox_reply_service.rb +5 -0
- data/app/services/collavre/link_preview_fetcher.rb +129 -38
- data/app/services/collavre/markdown_converter.rb +13 -3
- data/app/services/collavre/markdown_importer.rb +30 -6
- data/app/services/collavre/mobile/event_summarizer.rb +40 -0
- data/app/services/collavre/orchestration/agent_context_builder.rb +1 -15
- data/app/services/collavre/orchestration/agent_orchestrator.rb +34 -8
- 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/system_events/context_builder.rb +1 -14
- 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/tools/preview_attach_service.rb +5 -7
- data/app/services/collavre/typo_corrector.rb +188 -0
- data/app/views/collavre/comments/_comment.html.erb +9 -4
- data/app/views/collavre/comments/_comments_popup.html.erb +25 -4
- data/app/views/collavre/creatives/_inline_edit_form.html.erb +10 -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/landing/show.html.erb +52 -10
- 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 +16 -0
- data/config/locales/comments.ko.yml +16 -0
- data/config/locales/creatives.en.yml +6 -0
- data/config/locales/creatives.ko.yml +5 -0
- data/config/locales/integrations.en.yml +1 -1
- data/config/locales/integrations.ko.yml +1 -1
- data/config/locales/landing.en.yml +22 -10
- data/config/locales/landing.ko.yml +22 -10
- 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/20260120045354_encrypt_oauth_tokens.rb +13 -4
- 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/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/db/seeds.rb +51 -0
- data/lib/collavre/version.rb +1 -1
- data/lib/generators/collavre/install/install_generator.rb +1 -0
- metadata +96 -2
- data/app/services/collavre/tools/description_normalizable.rb +0 -16
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createEditor,
|
|
3
|
+
$getRoot,
|
|
4
|
+
$createParagraphNode,
|
|
5
|
+
$createTextNode,
|
|
6
|
+
$getSelection
|
|
7
|
+
} from "lexical"
|
|
8
|
+
import { registerRichText } from "@lexical/rich-text"
|
|
9
|
+
import {
|
|
10
|
+
isSelectionAtDocumentStart,
|
|
11
|
+
isSelectionAtDocumentEnd
|
|
12
|
+
} from "../selection_boundary"
|
|
13
|
+
|
|
14
|
+
function buildEditor() {
|
|
15
|
+
const editor = createEditor({
|
|
16
|
+
namespace: "test",
|
|
17
|
+
onError(error) {
|
|
18
|
+
throw error
|
|
19
|
+
}
|
|
20
|
+
})
|
|
21
|
+
registerRichText(editor)
|
|
22
|
+
return editor
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Builds a document with the given lines (one paragraph per line) and places a
|
|
26
|
+
// collapsed cursor in the requested paragraph at the requested offset.
|
|
27
|
+
function setup(lines, { paragraphIndex, offset }) {
|
|
28
|
+
const editor = buildEditor()
|
|
29
|
+
let result = null
|
|
30
|
+
editor.update(
|
|
31
|
+
() => {
|
|
32
|
+
const root = $getRoot()
|
|
33
|
+
root.clear()
|
|
34
|
+
const textNodes = lines.map((line) => {
|
|
35
|
+
const p = $createParagraphNode()
|
|
36
|
+
const t = $createTextNode(line)
|
|
37
|
+
p.append(t)
|
|
38
|
+
root.append(p)
|
|
39
|
+
return t
|
|
40
|
+
})
|
|
41
|
+
const target = textNodes[paragraphIndex]
|
|
42
|
+
target.select(offset, offset)
|
|
43
|
+
},
|
|
44
|
+
{ discrete: true }
|
|
45
|
+
)
|
|
46
|
+
editor.getEditorState().read(() => {
|
|
47
|
+
const selection = $getSelection()
|
|
48
|
+
result = {
|
|
49
|
+
atStart: isSelectionAtDocumentStart(selection),
|
|
50
|
+
atEnd: isSelectionAtDocumentEnd(selection)
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
return result
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe("selection boundary helpers", () => {
|
|
57
|
+
it("treats cursor at the very start of the first line as document start", () => {
|
|
58
|
+
const r = setup(["line1", "line2"], { paragraphIndex: 0, offset: 0 })
|
|
59
|
+
expect(r.atStart).toBe(true)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it("does NOT treat start of the second paragraph as document start", () => {
|
|
63
|
+
// This is the bug: after Enter, the cursor sits at offset 0 of a new
|
|
64
|
+
// paragraph. ArrowUp must move the cursor up, not jump to the row above.
|
|
65
|
+
const r = setup(["line1", "line2"], { paragraphIndex: 1, offset: 0 })
|
|
66
|
+
expect(r.atStart).toBe(false)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it("does NOT treat mid-text cursor as document start", () => {
|
|
70
|
+
const r = setup(["line1"], { paragraphIndex: 0, offset: 2 })
|
|
71
|
+
expect(r.atStart).toBe(false)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it("treats cursor at the very end of the last line as document end", () => {
|
|
75
|
+
const r = setup(["line1", "line2"], { paragraphIndex: 1, offset: 5 })
|
|
76
|
+
expect(r.atEnd).toBe(true)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it("does NOT treat end of the first paragraph as document end", () => {
|
|
80
|
+
const r = setup(["line1", "line2"], { paragraphIndex: 0, offset: 5 })
|
|
81
|
+
expect(r.atEnd).toBe(false)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it("does NOT treat mid-text cursor as document end", () => {
|
|
85
|
+
const r = setup(["line1"], { paragraphIndex: 0, offset: 2 })
|
|
86
|
+
expect(r.atEnd).toBe(false)
|
|
87
|
+
})
|
|
88
|
+
})
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { createEditor, $getRoot, $createParagraphNode, $createTextNode } from "lexical"
|
|
2
|
+
import { $convertFromMarkdownString, $convertToMarkdownString } from "@lexical/markdown"
|
|
3
|
+
import {
|
|
4
|
+
TableNode,
|
|
5
|
+
TableRowNode,
|
|
6
|
+
TableCellNode,
|
|
7
|
+
$createTableNode,
|
|
8
|
+
$createTableRowNode,
|
|
9
|
+
$createTableCellNode,
|
|
10
|
+
TableCellHeaderStates
|
|
11
|
+
} from "@lexical/table"
|
|
12
|
+
import { HeadingNode, QuoteNode } 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 { MARKDOWN_TRANSFORMERS } from "../markdown_serialize"
|
|
17
|
+
|
|
18
|
+
// Drives a headless editor through markdown -> Lexical -> markdown so the TABLE
|
|
19
|
+
// transformer's import (replace) and export paths are exercised together.
|
|
20
|
+
function makeEditor() {
|
|
21
|
+
return createEditor({
|
|
22
|
+
namespace: "table-test",
|
|
23
|
+
onError(error) {
|
|
24
|
+
throw error
|
|
25
|
+
},
|
|
26
|
+
nodes: [
|
|
27
|
+
HeadingNode,
|
|
28
|
+
QuoteNode,
|
|
29
|
+
ListNode,
|
|
30
|
+
ListItemNode,
|
|
31
|
+
LinkNode,
|
|
32
|
+
CodeNode,
|
|
33
|
+
CodeHighlightNode,
|
|
34
|
+
TableNode,
|
|
35
|
+
TableRowNode,
|
|
36
|
+
TableCellNode
|
|
37
|
+
]
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function roundTrip(markdown) {
|
|
42
|
+
const editor = makeEditor()
|
|
43
|
+
editor.update(
|
|
44
|
+
() => {
|
|
45
|
+
$convertFromMarkdownString(markdown, MARKDOWN_TRANSFORMERS)
|
|
46
|
+
},
|
|
47
|
+
{ discrete: true }
|
|
48
|
+
)
|
|
49
|
+
let result = ""
|
|
50
|
+
editor.getEditorState().read(() => {
|
|
51
|
+
result = $convertToMarkdownString(MARKDOWN_TRANSFORMERS)
|
|
52
|
+
})
|
|
53
|
+
return result
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Build a 1x1 table whose only cell holds the given literal text, export it to
|
|
57
|
+
// markdown, then re-import and read the cell back — i.e. a true save -> reopen
|
|
58
|
+
// cycle that never assumes how the text is escaped on the wire.
|
|
59
|
+
function saveReopenCellText(cellText) {
|
|
60
|
+
const writer = makeEditor()
|
|
61
|
+
let markdown = ""
|
|
62
|
+
writer.update(
|
|
63
|
+
() => {
|
|
64
|
+
const cell = $createTableCellNode(TableCellHeaderStates.NO_STATUS)
|
|
65
|
+
cell.append($createParagraphNode().append($createTextNode(cellText)))
|
|
66
|
+
const table = $createTableNode()
|
|
67
|
+
table.append($createTableRowNode().append(cell))
|
|
68
|
+
$getRoot().append(table)
|
|
69
|
+
},
|
|
70
|
+
{ discrete: true }
|
|
71
|
+
)
|
|
72
|
+
writer.getEditorState().read(() => {
|
|
73
|
+
markdown = $convertToMarkdownString(MARKDOWN_TRANSFORMERS)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
const reader = makeEditor()
|
|
77
|
+
reader.update(
|
|
78
|
+
() => {
|
|
79
|
+
$convertFromMarkdownString(markdown, MARKDOWN_TRANSFORMERS)
|
|
80
|
+
},
|
|
81
|
+
{ discrete: true }
|
|
82
|
+
)
|
|
83
|
+
let text = null
|
|
84
|
+
reader.getEditorState().read(() => {
|
|
85
|
+
const table = $getRoot()
|
|
86
|
+
.getChildren()
|
|
87
|
+
.find((n) => n.getType() === "table")
|
|
88
|
+
text = table ? table.getFirstChild().getFirstChild().getTextContent() : null
|
|
89
|
+
})
|
|
90
|
+
return text
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
describe("TABLE markdown transformer", () => {
|
|
94
|
+
it("round-trips a header + body table", () => {
|
|
95
|
+
const md = "| Name | Age |\n| --- | --- |\n| Ada | 36 |\n| Linus | 54 |"
|
|
96
|
+
const out = roundTrip(md)
|
|
97
|
+
expect(out).toContain("| Name | Age |")
|
|
98
|
+
expect(out).toContain("| --- | --- |")
|
|
99
|
+
expect(out).toContain("| Ada | 36 |")
|
|
100
|
+
expect(out).toContain("| Linus | 54 |")
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it("round-trips a table with an empty cell", () => {
|
|
104
|
+
const md = "| A | B |\n| --- | --- |\n| | y |"
|
|
105
|
+
const out = roundTrip(md)
|
|
106
|
+
expect(out).toContain("| A | B |")
|
|
107
|
+
expect(out).toContain("| | y |")
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it("preserves inline formatting inside cells", () => {
|
|
111
|
+
const md = "| H |\n| --- |\n| **bold** |"
|
|
112
|
+
expect(roundTrip(md)).toContain("**bold**")
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it("leaves a non-table paragraph untouched", () => {
|
|
116
|
+
const md = "Just a sentence."
|
|
117
|
+
expect(roundTrip(md).trim()).toBe("Just a sentence.")
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it("treats an alignment divider (leading/trailing colons) as a header", () => {
|
|
121
|
+
const md = "| L | R |\n| :--- | ---: |\n| a | b |"
|
|
122
|
+
const out = roundTrip(md)
|
|
123
|
+
// A divider row promotes the prior row to a header, which re-exports as "---".
|
|
124
|
+
expect(out).toContain("| L | R |")
|
|
125
|
+
expect(out).toContain("| --- | --- |")
|
|
126
|
+
expect(out).toContain("| a | b |")
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it("preserves a literal pipe typed inside a cell", () => {
|
|
130
|
+
// A user types "a|b" into a cell. Without escaping, export emits an
|
|
131
|
+
// unescaped pipe which GFM re-parses as an extra column on the next render.
|
|
132
|
+
const md = "| H1 | H2 |\n| --- | --- |\n| a\\|b | ok |"
|
|
133
|
+
const out = roundTrip(md)
|
|
134
|
+
expect(out).toContain("| a\\|b | ok |")
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it("save->reopen preserves a literal backslash-n (not a newline) in a cell", () => {
|
|
138
|
+
// "a\nb" is the literal chars a, backslash, n, b (e.g. a regex or path), NOT
|
|
139
|
+
// a newline. A prior bug decoded the backslash-n into a real newline, splitting
|
|
140
|
+
// the cell (CodeQL #42). The escape must be complete (CodeQL #43, backslash).
|
|
141
|
+
expect(saveReopenCellText("a\\nb")).toBe("a\\nb")
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it("save->reopen preserves a literal backslash in a cell", () => {
|
|
145
|
+
expect(saveReopenCellText("C:\\path")).toBe("C:\\path")
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it("save->reopen preserves a backslash directly before a pipe in a cell", () => {
|
|
149
|
+
// The adversarial case for incomplete escaping: a backslash adjacent to the
|
|
150
|
+
// pipe that gets escaped. Both must survive intact.
|
|
151
|
+
expect(saveReopenCellText("a\\|b")).toBe("a\\|b")
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it("does not hang on a pathological colon/pipe row (ReDoS guard)", () => {
|
|
155
|
+
// Before the -+ fix, the divider regex backtracked exponentially on rows of
|
|
156
|
+
// alternating colons and pipes. This must complete effectively instantly.
|
|
157
|
+
const evil = "|" + Array(40).fill(":").join("|") + "|x"
|
|
158
|
+
const start = process.hrtime.bigint()
|
|
159
|
+
roundTrip(evil)
|
|
160
|
+
const ms = Number(process.hrtime.bigint() - start) / 1e6
|
|
161
|
+
expect(ms).toBeLessThan(1000)
|
|
162
|
+
})
|
|
163
|
+
})
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import {
|
|
5
|
+
createEditor,
|
|
6
|
+
$getRoot,
|
|
7
|
+
$createParagraphNode,
|
|
8
|
+
$createTextNode,
|
|
9
|
+
$applyNodeReplacement,
|
|
10
|
+
DecoratorNode,
|
|
11
|
+
} from "lexical"
|
|
12
|
+
import { ensureTrailingParagraph, registerTrailingParagraph } from "../trailing_paragraph"
|
|
13
|
+
|
|
14
|
+
// Stand-in for the production image/video/attachment nodes (those are .jsx and
|
|
15
|
+
// can't be imported here). Only the graph-relevant shape matters: a "image"
|
|
16
|
+
// DecoratorNode that is NOT inline, exactly like image_node.jsx.
|
|
17
|
+
class TestImageNode extends DecoratorNode {
|
|
18
|
+
__src
|
|
19
|
+
static getType() { return "image" }
|
|
20
|
+
static clone(n) { return new TestImageNode(n.__src, n.__key) }
|
|
21
|
+
static importJSON() { return $createImage("") }
|
|
22
|
+
constructor(src = "", key) { super(key); this.__src = src }
|
|
23
|
+
createDOM() { return document.createElement("span") }
|
|
24
|
+
updateDOM() { return false }
|
|
25
|
+
decorate() { return null }
|
|
26
|
+
}
|
|
27
|
+
function $createImage(src) { return $applyNodeReplacement(new TestImageNode(src)) }
|
|
28
|
+
|
|
29
|
+
function newEditor() {
|
|
30
|
+
return createEditor({ namespace: "t", nodes: [TestImageNode], onError: (e) => { throw e } })
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function rootTypes(editor) {
|
|
34
|
+
let types = []
|
|
35
|
+
editor.getEditorState().read(() => {
|
|
36
|
+
types = $getRoot().getChildren().map((c) => c.getType())
|
|
37
|
+
})
|
|
38
|
+
return types
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("ensureTrailingParagraph (pure helper)", () => {
|
|
42
|
+
test("appends a paragraph when the last root child is a decorator", () => {
|
|
43
|
+
const editor = newEditor()
|
|
44
|
+
editor.update(() => {
|
|
45
|
+
const root = $getRoot()
|
|
46
|
+
root.clear()
|
|
47
|
+
const p = $createParagraphNode()
|
|
48
|
+
p.append($createTextNode("hello"))
|
|
49
|
+
root.append(p, $createImage("https://lipcoding.kr/x.png"))
|
|
50
|
+
ensureTrailingParagraph(root)
|
|
51
|
+
}, { discrete: true })
|
|
52
|
+
expect(rootTypes(editor)).toEqual(["paragraph", "image", "paragraph"])
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test("image-only content becomes editable (image + trailing paragraph)", () => {
|
|
56
|
+
const editor = newEditor()
|
|
57
|
+
editor.update(() => {
|
|
58
|
+
const root = $getRoot()
|
|
59
|
+
root.clear()
|
|
60
|
+
root.append($createImage("https://lipcoding.kr/x.png"))
|
|
61
|
+
ensureTrailingParagraph(root)
|
|
62
|
+
}, { discrete: true })
|
|
63
|
+
expect(rootTypes(editor)).toEqual(["image", "paragraph"])
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test("no-op when the last child is already a paragraph", () => {
|
|
67
|
+
const editor = newEditor()
|
|
68
|
+
editor.update(() => {
|
|
69
|
+
const root = $getRoot()
|
|
70
|
+
root.clear()
|
|
71
|
+
root.append($createImage("a"), $createParagraphNode())
|
|
72
|
+
ensureTrailingParagraph(root)
|
|
73
|
+
}, { discrete: true })
|
|
74
|
+
expect(rootTypes(editor)).toEqual(["image", "paragraph"])
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
describe("registerTrailingParagraph (RootNode transform)", () => {
|
|
79
|
+
test("fires on update: trailing image gets a paragraph appended automatically", () => {
|
|
80
|
+
const editor = newEditor()
|
|
81
|
+
registerTrailingParagraph(editor)
|
|
82
|
+
// Simulate editing that leaves an image as the last block.
|
|
83
|
+
editor.update(() => {
|
|
84
|
+
const root = $getRoot()
|
|
85
|
+
root.clear()
|
|
86
|
+
const p = $createParagraphNode()
|
|
87
|
+
p.append($createTextNode("hello"))
|
|
88
|
+
root.append(p, $createImage("https://lipcoding.kr/x.png"))
|
|
89
|
+
}, { discrete: true })
|
|
90
|
+
expect(rootTypes(editor)).toEqual(["paragraph", "image", "paragraph"])
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test("terminates (does not append unboundedly)", () => {
|
|
94
|
+
const editor = newEditor()
|
|
95
|
+
registerTrailingParagraph(editor)
|
|
96
|
+
editor.update(() => {
|
|
97
|
+
const root = $getRoot()
|
|
98
|
+
root.clear()
|
|
99
|
+
root.append($createImage("a"))
|
|
100
|
+
}, { discrete: true })
|
|
101
|
+
// exactly one trailing paragraph, not many
|
|
102
|
+
expect(rootTypes(editor)).toEqual(["image", "paragraph"])
|
|
103
|
+
})
|
|
104
|
+
})
|
|
@@ -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
|
+
}
|