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,210 @@
|
|
|
1
|
+
import {
|
|
2
|
+
$createParagraphNode,
|
|
3
|
+
$getSelection,
|
|
4
|
+
$isRangeSelection,
|
|
5
|
+
COMMAND_PRIORITY_LOW,
|
|
6
|
+
INDENT_CONTENT_COMMAND,
|
|
7
|
+
KEY_TAB_COMMAND,
|
|
8
|
+
OUTDENT_CONTENT_COMMAND
|
|
9
|
+
} from "lexical"
|
|
10
|
+
import { $createListNode, $isListItemNode, $isListNode } from "@lexical/list"
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Tab / Shift+Tab indentation scoped to list items.
|
|
14
|
+
*
|
|
15
|
+
* Inside a list, Tab nests the current item (INDENT_CONTENT_COMMAND).
|
|
16
|
+
* Shift+Tab un-nests it (OUTDENT_CONTENT_COMMAND) when the item is nested;
|
|
17
|
+
* @lexical/list turns those indent changes into real nested <ul>/<ol> structure.
|
|
18
|
+
*
|
|
19
|
+
* At the OUTERMOST level OUTDENT_CONTENT_COMMAND is a no-op (there is no shallower
|
|
20
|
+
* indent to drop to), so Shift+Tab on a top-level item would do nothing. Other
|
|
21
|
+
* editors instead remove the list formatting for that item — the item leaves the
|
|
22
|
+
* list and becomes a normal paragraph. We replicate that here so Shift+Tab always
|
|
23
|
+
* has a visible, expected effect.
|
|
24
|
+
*
|
|
25
|
+
* Outside a list the command is ignored (returns false) so Tab keeps its default
|
|
26
|
+
* behaviour (moving focus out of the editor) and plain paragraphs never gain an
|
|
27
|
+
* indent the Markdown-canonical store can't represent cleanly.
|
|
28
|
+
*
|
|
29
|
+
* Returns the editor.registerCommand teardown so callers can clean up.
|
|
30
|
+
*/
|
|
31
|
+
export function registerListTabIndentation(editor) {
|
|
32
|
+
return editor.registerCommand(
|
|
33
|
+
KEY_TAB_COMMAND,
|
|
34
|
+
(event) => {
|
|
35
|
+
const selection = $getSelection()
|
|
36
|
+
if (!$isRangeSelection(selection)) return false
|
|
37
|
+
const listItem = $findListItemAncestor(selection)
|
|
38
|
+
if (!listItem) return false
|
|
39
|
+
|
|
40
|
+
event.preventDefault()
|
|
41
|
+
|
|
42
|
+
if (event.shiftKey) {
|
|
43
|
+
// Top-level item: drop out of the list entirely (other editors' behaviour),
|
|
44
|
+
// because OUTDENT has nothing shallower to move to. Nested item: un-nest one
|
|
45
|
+
// level via OUTDENT, which @lexical/list collapses back into flat structure.
|
|
46
|
+
if ($isTopLevelListItem(listItem)) {
|
|
47
|
+
// A selection can span several top-level items; un-list each so multi-item
|
|
48
|
+
// Shift+Tab matches the range-aware OUTDENT path instead of only dropping
|
|
49
|
+
// the anchor's item. Collect the items up front (converting one mutates the
|
|
50
|
+
// tree) and convert in document order — each conversion splits the list and
|
|
51
|
+
// leaves the next selected item at the head of the trailing list, so the
|
|
52
|
+
// result is clean sequential paragraphs.
|
|
53
|
+
$selectedTopLevelListItems(selection, listItem).forEach(
|
|
54
|
+
$convertListItemToParagraph
|
|
55
|
+
)
|
|
56
|
+
return true
|
|
57
|
+
}
|
|
58
|
+
return editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return editor.dispatchCommand(INDENT_CONTENT_COMMAND, undefined)
|
|
62
|
+
},
|
|
63
|
+
COMMAND_PRIORITY_LOW
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// A list item that exists only to hold a nested list (its first/only child is a
|
|
68
|
+
// ListNode). @lexical/list uses this wrapper to represent indentation.
|
|
69
|
+
function $isNestedListItemWrapper(node) {
|
|
70
|
+
return $isListItemNode(node) && $isListNode(node.getFirstChild())
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function $findListItemAncestor(selection) {
|
|
74
|
+
const anchorNode = selection.anchor.getNode()
|
|
75
|
+
if ($isListItemNode(anchorNode)) return anchorNode
|
|
76
|
+
return anchorNode.getParents().find($isListItemNode) || null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// The distinct top-level list items the selection touches, in document order.
|
|
80
|
+
// getNodes() returns the range's nodes in document order; we map each to its
|
|
81
|
+
// owning list item and keep only top-level, non-wrapper ones (nested items go
|
|
82
|
+
// through OUTDENT instead). Falls back to the anchor item when the range yields
|
|
83
|
+
// nothing useful (e.g. a collapsed selection on an empty item).
|
|
84
|
+
function $selectedTopLevelListItems(selection, fallbackItem) {
|
|
85
|
+
const seen = new Set()
|
|
86
|
+
const items = []
|
|
87
|
+
selection.getNodes().forEach((node) => {
|
|
88
|
+
const listItem = $isListItemNode(node)
|
|
89
|
+
? node
|
|
90
|
+
: node.getParents().find($isListItemNode)
|
|
91
|
+
if (!listItem || seen.has(listItem.getKey())) return
|
|
92
|
+
if (!$isTopLevelListItem(listItem) || $isNestedListItemWrapper(listItem)) return
|
|
93
|
+
seen.add(listItem.getKey())
|
|
94
|
+
items.push(listItem)
|
|
95
|
+
})
|
|
96
|
+
if (items.length === 0 && fallbackItem) items.push(fallbackItem)
|
|
97
|
+
return items
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// A list item is "top level" when its parent list is not nested inside another
|
|
101
|
+
// list item. This holds both at the document root and inside a blockquote (where
|
|
102
|
+
// the list's parent is the QuoteNode, not the root) — in both cases OUTDENT has
|
|
103
|
+
// nothing shallower to drop to, so the item should leave the list instead.
|
|
104
|
+
function $isTopLevelListItem(listItem) {
|
|
105
|
+
const parentList = listItem.getParent()
|
|
106
|
+
if (!$isListNode(parentList)) return false
|
|
107
|
+
return !$isListItemNode(parentList.getParent())
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Remove a single top-level list item from its list, turning it into a paragraph
|
|
111
|
+
// in the same position. Items before it stay in the original list; items after it
|
|
112
|
+
// move into a fresh trailing list so the surrounding list is split, not flattened.
|
|
113
|
+
function $convertListItemToParagraph(listItem) {
|
|
114
|
+
const parentList = listItem.getParent()
|
|
115
|
+
if (!$isListNode(parentList)) return
|
|
116
|
+
|
|
117
|
+
const following = listItem.getNextSiblings()
|
|
118
|
+
// An item can hold both inline content (text) and a nested child list. Only the
|
|
119
|
+
// inline content belongs in the paragraph — a nested ListNode inside a paragraph
|
|
120
|
+
// is invalid block structure, so it is promoted to a sibling list block instead.
|
|
121
|
+
const children = listItem.getChildren()
|
|
122
|
+
const nestedLists = children.filter($isListNode)
|
|
123
|
+
const inlineChildren = children.filter((child) => !$isListNode(child))
|
|
124
|
+
const paragraph = $createParagraphNode()
|
|
125
|
+
|
|
126
|
+
parentList.insertAfter(paragraph)
|
|
127
|
+
|
|
128
|
+
// Keep block order: paragraph, then any promoted nested lists, then the split-off
|
|
129
|
+
// trailing items, by chaining insertAfter off the previously inserted block.
|
|
130
|
+
let anchor = paragraph
|
|
131
|
+
nestedLists.forEach((nestedList) => {
|
|
132
|
+
anchor.insertAfter(nestedList)
|
|
133
|
+
anchor = nestedList
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
if (following.length > 0) {
|
|
137
|
+
const listType = parentList.getListType()
|
|
138
|
+
// @lexical/list stores a nested child list as a *wrapper* list item (its only
|
|
139
|
+
// child is a ListNode) placed right after the parent item. The leading run of
|
|
140
|
+
// such wrappers are the removed item's own children: promote each wrapper's
|
|
141
|
+
// inner ListNode up one level as its own block, then drop the emptied wrapper.
|
|
142
|
+
// Moving the whole ListNode (not just its items) preserves the child list's own
|
|
143
|
+
// marker type — a bullet child of a numbered parent must stay a bullet list, not
|
|
144
|
+
// get renumbered. Promoting items rather than the wrapper also empties parentList
|
|
145
|
+
// so the cleanup below can remove it, avoiding an orphan <li><ul>. Once a real
|
|
146
|
+
// item appears the run ends; later wrappers belong to it and must stay nested.
|
|
147
|
+
let promotingChildren = true
|
|
148
|
+
const trailingItems = []
|
|
149
|
+
following.forEach((sibling) => {
|
|
150
|
+
if (promotingChildren && $isNestedListItemWrapper(sibling)) {
|
|
151
|
+
const childList = sibling.getFirstChild()
|
|
152
|
+
anchor.insertAfter(childList)
|
|
153
|
+
anchor = childList
|
|
154
|
+
sibling.remove()
|
|
155
|
+
} else {
|
|
156
|
+
promotingChildren = false
|
|
157
|
+
trailingItems.push(sibling)
|
|
158
|
+
}
|
|
159
|
+
})
|
|
160
|
+
if (trailingItems.length > 0) {
|
|
161
|
+
// For ordered lists the trailing items must keep their original numbers
|
|
162
|
+
// (e.g. "1. a / 2. b / 3. c" → outdent b → c stays 3, not restart at 1).
|
|
163
|
+
//
|
|
164
|
+
// When a same-type list was promoted directly before these items (the removed
|
|
165
|
+
// item's own ordered child list), Lexical's ListNode transform merges the two
|
|
166
|
+
// adjacent same-type lists and keeps the *first* list's start — a separate
|
|
167
|
+
// trailing list's start would be silently discarded. So append the trailing
|
|
168
|
+
// items into that promoted list instead and set its start so the first trailing
|
|
169
|
+
// item lands on its original value, with the promoted children filling the slots
|
|
170
|
+
// leading up to it ("1. a / 2. b / [1.] b1 / 3. c" → outdent b → "2. b1 / 3. c").
|
|
171
|
+
const mergesWithPromoted =
|
|
172
|
+
$isListNode(anchor) && anchor.getListType() === listType
|
|
173
|
+
if (mergesWithPromoted) {
|
|
174
|
+
if (listType === "number") {
|
|
175
|
+
// Count real items already in the promoted list (nested wrappers don't
|
|
176
|
+
// advance numbering) so the first trailing item keeps its original value.
|
|
177
|
+
const promotedItems = anchor
|
|
178
|
+
.getChildren()
|
|
179
|
+
.filter((child) => !$isNestedListItemWrapper(child)).length
|
|
180
|
+
// Clamp to 1: when more children are promoted than the removed item's
|
|
181
|
+
// value frees up (e.g. "1. b" with children b1, b2 → 1 + 1 - 2 = 0),
|
|
182
|
+
// a 0/negative start would render literally as "0."/"-1.". Falling back
|
|
183
|
+
// to 1 just renumbers the merged list sequentially from the top.
|
|
184
|
+
anchor.setStart(Math.max(1, listItem.getValue() + 1 - promotedItems))
|
|
185
|
+
}
|
|
186
|
+
trailingItems.forEach((sibling) => anchor.append(sibling))
|
|
187
|
+
} else {
|
|
188
|
+
const trailingStart =
|
|
189
|
+
listType === "number" ? listItem.getValue() + 1 : undefined
|
|
190
|
+
const trailingList = $createListNode(listType, trailingStart)
|
|
191
|
+
trailingItems.forEach((sibling) => trailingList.append(sibling))
|
|
192
|
+
anchor.insertAfter(trailingList)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (inlineChildren.length > 0) {
|
|
198
|
+
// Moving the existing nodes (not cloning) keeps any caret pointing into them
|
|
199
|
+
// valid, so the cursor stays where the user was typing.
|
|
200
|
+
inlineChildren.forEach((child) => paragraph.append(child))
|
|
201
|
+
} else {
|
|
202
|
+
paragraph.select()
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
listItem.remove()
|
|
206
|
+
|
|
207
|
+
if (parentList.getChildrenSize() === 0) {
|
|
208
|
+
parentList.remove()
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import {
|
|
2
|
+
$isTextNode,
|
|
3
|
+
$isParagraphNode,
|
|
4
|
+
$isLineBreakNode,
|
|
5
|
+
$createParagraphNode
|
|
6
|
+
} from "lexical"
|
|
7
|
+
import { $convertToMarkdownString, TRANSFORMERS } from "@lexical/markdown"
|
|
8
|
+
import { TABLE, setCellTransformers } from "./table_transformer"
|
|
9
|
+
|
|
10
|
+
// Serializes the Lexical editor state to Markdown as the canonical storage
|
|
11
|
+
// format. Standard block/inline features (headings, lists, quotes, code,
|
|
12
|
+
// bold/italic/strikethrough, inline code, links) round-trip through Markdown
|
|
13
|
+
// via the upstream TRANSFORMERS. Features Markdown can't express are emitted as
|
|
14
|
+
// a SMALL, FIXED set of normalized inline HTML fragments:
|
|
15
|
+
//
|
|
16
|
+
// - text color / background-color -> <span style="...">
|
|
17
|
+
// - images / videos / attachments -> raw <img>/<video>/<a download> tags
|
|
18
|
+
//
|
|
19
|
+
// The HTML shape is normalized (one canonical form per feature) and the CSS
|
|
20
|
+
// values are validated, so the server-side sanitizer can safelist exactly these
|
|
21
|
+
// and the import path (markdown -> HTML -> Lexical) reconstructs them losslessly.
|
|
22
|
+
|
|
23
|
+
// Only these CSS color values are allowed inside an emitted <span>. Anything
|
|
24
|
+
// else (url(), expression(), javascript:, <, >) is dropped so a crafted inline
|
|
25
|
+
// style can't smuggle script or external requests into stored Markdown.
|
|
26
|
+
const SAFE_COLOR_VALUE =
|
|
27
|
+
/^(#[0-9a-fA-F]{3,8}|rgba?\([0-9.,%\s]+\)|hsla?\([0-9.,%\s]+\)|var\(--[a-zA-Z0-9-_]+\)|[a-zA-Z]+)$/
|
|
28
|
+
|
|
29
|
+
function safeColorValue(value) {
|
|
30
|
+
if (!value) return null
|
|
31
|
+
const v = value.trim().replace(/;+$/, "").trim()
|
|
32
|
+
if (!v) return null
|
|
33
|
+
if (/url\(|expression|javascript:|@import|[<>]/i.test(v)) return null
|
|
34
|
+
return SAFE_COLOR_VALUE.test(v) ? v : null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function colorBgFromStyle(styleText) {
|
|
38
|
+
let color = null
|
|
39
|
+
let background = null
|
|
40
|
+
;(styleText || "").split(";").forEach((decl) => {
|
|
41
|
+
const idx = decl.indexOf(":")
|
|
42
|
+
if (idx === -1) return
|
|
43
|
+
const key = decl.slice(0, idx).trim().toLowerCase()
|
|
44
|
+
const value = decl.slice(idx + 1)
|
|
45
|
+
if (key === "color") color = safeColorValue(value)
|
|
46
|
+
else if (key === "background-color") background = safeColorValue(value)
|
|
47
|
+
})
|
|
48
|
+
return { color, background }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function escapeHtmlAttr(value) {
|
|
52
|
+
return String(value == null ? "" : value)
|
|
53
|
+
.replace(/&/g, "&")
|
|
54
|
+
.replace(/"/g, """)
|
|
55
|
+
.replace(/</g, "<")
|
|
56
|
+
.replace(/>/g, ">")
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function escapeHtmlText(value) {
|
|
60
|
+
return String(value == null ? "" : value)
|
|
61
|
+
.replace(/&/g, "&")
|
|
62
|
+
.replace(/</g, "<")
|
|
63
|
+
.replace(/>/g, ">")
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Wrap already-formatted inner Markdown text in a normalized colored <span>, or
|
|
67
|
+
// return null when the style carries no (safe) color/background. The declaration
|
|
68
|
+
// order is fixed (color first) so the same selection always serializes to the
|
|
69
|
+
// same bytes — line-diff/snapshot stability depends on this.
|
|
70
|
+
export function colorSpanMarkup(styleText, inner) {
|
|
71
|
+
const { color, background } = colorBgFromStyle(styleText)
|
|
72
|
+
if (!color && !background) return null
|
|
73
|
+
const decls = []
|
|
74
|
+
if (color) decls.push(`color: ${color}`)
|
|
75
|
+
if (background) decls.push(`background-color: ${background}`)
|
|
76
|
+
// `inner` is the Markdown-formatted text of a colored node. Lexical's export
|
|
77
|
+
// escapes Markdown punctuation but NOT HTML metacharacters, so colored text
|
|
78
|
+
// like `<foo>` would become raw markup inside the <span> and get reinterpreted
|
|
79
|
+
// (and stripped) by the Markdown renderer/sanitizer. Escape <, >, & so user
|
|
80
|
+
// text stays text. Markdown syntax chars (*, _, `, ~) are left untouched.
|
|
81
|
+
return `<span style="${decls.join("; ")}">${escapeHtmlText(inner)}</span>`
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Canonical inline HTML for the decorator nodes, matching the shapes the import
|
|
85
|
+
// path (markdown_to_html -> $generateNodesFromDOM) and the server sanitizer
|
|
86
|
+
// expect. Numeric width/height only — Lexical's "inherit" sentinel is dropped.
|
|
87
|
+
export function imageMarkup({ src, altText, width, height }) {
|
|
88
|
+
let html = `<img src="${escapeHtmlAttr(src)}" alt="${escapeHtmlAttr(altText)}"`
|
|
89
|
+
if (Number.isFinite(Number(width)) && Number(width) > 0) {
|
|
90
|
+
html += ` width="${escapeHtmlAttr(width)}"`
|
|
91
|
+
}
|
|
92
|
+
if (Number.isFinite(Number(height)) && Number(height) > 0) {
|
|
93
|
+
html += ` height="${escapeHtmlAttr(height)}"`
|
|
94
|
+
}
|
|
95
|
+
return `${html}>`
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function videoMarkup({ src }) {
|
|
99
|
+
return `<video controls src="${escapeHtmlAttr(src)}"></video>`
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function attachmentMarkup({ src, filename, filesize }) {
|
|
103
|
+
let html = `<a href="${escapeHtmlAttr(src)}" download="${escapeHtmlAttr(filename)}"`
|
|
104
|
+
if (filesize != null && filesize !== "") {
|
|
105
|
+
html += ` data-filesize="${escapeHtmlAttr(filesize)}"`
|
|
106
|
+
}
|
|
107
|
+
return `${html}>${escapeHtmlText(filename)}</a>`
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Boilerplate so a transformer can be export-only: import never fires.
|
|
111
|
+
const NEVER = /(?!)/
|
|
112
|
+
|
|
113
|
+
function exportOnlyTransformer(exportFn, { dependencies = [], type = "text-match" } = {}) {
|
|
114
|
+
return {
|
|
115
|
+
dependencies,
|
|
116
|
+
export: exportFn,
|
|
117
|
+
importRegExp: NEVER,
|
|
118
|
+
regExp: NEVER,
|
|
119
|
+
replace: () => false,
|
|
120
|
+
trigger: "",
|
|
121
|
+
type
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Decorator nodes -> raw HTML. Duck-typed via getType() so this module stays
|
|
126
|
+
// free of the .jsx node classes (kept importable under native-ESM Jest).
|
|
127
|
+
function decoratorMarkup(node) {
|
|
128
|
+
const type = node.getType ? node.getType() : null
|
|
129
|
+
if (type === "image") {
|
|
130
|
+
return imageMarkup({
|
|
131
|
+
src: node.getSrc?.(),
|
|
132
|
+
altText: node.getAltText?.(),
|
|
133
|
+
width: node.__width,
|
|
134
|
+
height: node.__height
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
if (type === "video") {
|
|
138
|
+
return videoMarkup({ src: node.getSrc?.() })
|
|
139
|
+
}
|
|
140
|
+
if (type === "attachment") {
|
|
141
|
+
return attachmentMarkup({
|
|
142
|
+
src: node.getSrc?.(),
|
|
143
|
+
filename: node.getFilename?.(),
|
|
144
|
+
filesize: node.__filesize
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
return null
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// A paragraph the user left blank: it carries no real text, only empty text
|
|
151
|
+
// nodes and/or line breaks. (Decorator- or text-bearing paragraphs are NOT
|
|
152
|
+
// blank, so media and real content keep their normal export.) An empty
|
|
153
|
+
// paragraph from pressing Enter has zero children; a run of blank lines that
|
|
154
|
+
// was reopened comes back as ONE paragraph holding N LineBreakNodes (the
|
|
155
|
+
// importer groups consecutive <br> markers together).
|
|
156
|
+
function isBlankParagraph(node) {
|
|
157
|
+
if (!$isParagraphNode(node)) return false
|
|
158
|
+
// A blank paragraph inside a table cell is just an empty cell — it must stay
|
|
159
|
+
// empty (`| |`), not become a `<br>`. The cell serializer reuses this
|
|
160
|
+
// transformer set, so exclude paragraphs nested in a cell (duck-typed by type
|
|
161
|
+
// to keep this module free of the @lexical/table import).
|
|
162
|
+
const parent = node.getParent ? node.getParent() : null
|
|
163
|
+
if (parent && parent.getType && parent.getType() === "tablecell") return false
|
|
164
|
+
return node
|
|
165
|
+
.getChildren()
|
|
166
|
+
.every((child) => $isLineBreakNode(child) || ($isTextNode(child) && !child.getTextContent().trim()))
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// How many blank lines a blank paragraph represents: one per LineBreakNode, but
|
|
170
|
+
// at least one (a freshly-typed empty paragraph has no line breaks yet still
|
|
171
|
+
// stands for a single blank line). This exact count is what keeps multiple
|
|
172
|
+
// blank lines from multiplying on every save — the reopened paragraph holds N
|
|
173
|
+
// line breaks and must re-emit N markers, not N+1.
|
|
174
|
+
function blankParagraphBreakCount(node) {
|
|
175
|
+
const breaks = node.getChildren().filter($isLineBreakNode).length
|
|
176
|
+
return Math.max(1, breaks)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Blank paragraphs -> one `<br>` per blank line. A `<br>` is a semantic empty
|
|
180
|
+
// line (not a stray space or NBSP): the hard-break renderers keep it as a
|
|
181
|
+
// visible blank line, and because each marker sits in its own block separated
|
|
182
|
+
// by the standard `\n\n`, a blank line after a list no longer gets pulled into
|
|
183
|
+
// the last <li> via lazy continuation. Round-trips: the importer regroups the
|
|
184
|
+
// rendered <br> elements into a blank paragraph that re-exports identically.
|
|
185
|
+
const BLANK_PARAGRAPH_TRANSFORMER = exportOnlyTransformer(
|
|
186
|
+
(node) =>
|
|
187
|
+
isBlankParagraph(node) ? Array(blankParagraphBreakCount(node)).fill("<br>").join("\n\n") : null,
|
|
188
|
+
{ type: "element" }
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
// Colored / highlighted text -> normalized <span>. Falls through (returns null)
|
|
192
|
+
// for uncolored text so the default text-format export still applies.
|
|
193
|
+
const COLOR_TRANSFORMER = exportOnlyTransformer((node, _exportChildren, exportFormat) => {
|
|
194
|
+
if (!$isTextNode(node)) return null
|
|
195
|
+
const style = node.getStyle ? node.getStyle() : ""
|
|
196
|
+
if (!style) return null
|
|
197
|
+
return colorSpanMarkup(style, exportFormat(node, node.getTextContent()))
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
// Decorator handler registered as a TEXT-MATCH transformer for media that lives
|
|
201
|
+
// INLINE inside a paragraph (claimed during exportChildren).
|
|
202
|
+
const DECORATOR_TEXT_TRANSFORMER = exportOnlyTransformer((node) => decoratorMarkup(node))
|
|
203
|
+
|
|
204
|
+
// The SAME handler registered as an ELEMENT transformer for media that is a
|
|
205
|
+
// direct child of the root (the upload plugin's no-selection append path, and
|
|
206
|
+
// imported block-level <img> nodes). $convertToMarkdownString only runs element
|
|
207
|
+
// transformers on top-level nodes — a top-level decorator that matches no element
|
|
208
|
+
// transformer falls back to DecoratorNode#getTextContent() (empty for media),
|
|
209
|
+
// silently dropping it from markdown_source. Without this, the first rich save
|
|
210
|
+
// loses every top-level image/video/file.
|
|
211
|
+
const DECORATOR_ELEMENT_TRANSFORMER = exportOnlyTransformer((node) => decoratorMarkup(node), {
|
|
212
|
+
type: "element"
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
// Our custom transformers run first so colored text and decorator nodes are
|
|
216
|
+
// claimed before the upstream defaults (which would drop their style/content).
|
|
217
|
+
export const MARKDOWN_TRANSFORMERS = [
|
|
218
|
+
TABLE,
|
|
219
|
+
DECORATOR_ELEMENT_TRANSFORMER,
|
|
220
|
+
BLANK_PARAGRAPH_TRANSFORMER,
|
|
221
|
+
DECORATOR_TEXT_TRANSFORMER,
|
|
222
|
+
COLOR_TRANSFORMER,
|
|
223
|
+
...TRANSFORMERS
|
|
224
|
+
]
|
|
225
|
+
|
|
226
|
+
// Tables serialize their cell content with the same transformer set. Injected
|
|
227
|
+
// here (rather than imported into table_transformer.js) to break the cycle.
|
|
228
|
+
setCellTransformers(MARKDOWN_TRANSFORMERS)
|
|
229
|
+
|
|
230
|
+
// Fenced code blocks may legitimately contain consecutive blank lines; guard
|
|
231
|
+
// them so the blank-line normalization below never touches a code sample.
|
|
232
|
+
const FENCE_BLOCK = /(`{3,}|~{3,})[^\n]*\n[\s\S]*?\n\1[ \t]*(?=\n|$)/g
|
|
233
|
+
|
|
234
|
+
// Inline code spans (`...`) may legitimately contain the literal text `<br>` or
|
|
235
|
+
// a lone NBSP; guard them too so the blank-line normalization never rewrites the
|
|
236
|
+
// span (which would break it on save). Matched after fences are stashed, so the
|
|
237
|
+
// remaining backtick runs are genuine inline code.
|
|
238
|
+
const INLINE_CODE = /(`+)[^\n]*?\1/g
|
|
239
|
+
|
|
240
|
+
// Normalize the canonical Markdown projection. Enter produces a real paragraph
|
|
241
|
+
// break (standard `\n\n` separation); a blank line the user typed is preserved
|
|
242
|
+
// as a `<br>` marker (see BLANK_PARAGRAPH_TRANSFORMER). This pass only:
|
|
243
|
+
// - returns "" for a document with no real content (only whitespace and/or
|
|
244
|
+
// `<br>` markers), keeping the empty-state placeholder/presence contract,
|
|
245
|
+
// - migrates legacy NBSP-only blank-line markers (pre-`<br>` content) to a
|
|
246
|
+
// real `<br>` marker so they stop acting as a CommonMark lazy continuation,
|
|
247
|
+
// - isolates every `<br>` marker as its own block,
|
|
248
|
+
// - collapses runs of 3+ newlines to one blank line so block separation stays
|
|
249
|
+
// canonical and the very first save is round-trip stable,
|
|
250
|
+
// - trims trailing whitespace.
|
|
251
|
+
// Blank lines and literal `<br>`/NBSP inside code (fenced or inline) are
|
|
252
|
+
// preserved verbatim.
|
|
253
|
+
export function normalizeMarkdownBlankLines(markdown) {
|
|
254
|
+
// A document of only blank lines (each now a `<br>`) carries no real content,
|
|
255
|
+
// so strip the markers before the emptiness check.
|
|
256
|
+
if (!String(markdown).replace(/<br\s*\/?>/gi, "").trim()) return ""
|
|
257
|
+
|
|
258
|
+
const guards = []
|
|
259
|
+
const stash = (match) => `\x00MDGUARD${guards.push(match) - 1}\x00`
|
|
260
|
+
const guarded = String(markdown)
|
|
261
|
+
.replace(FENCE_BLOCK, stash)
|
|
262
|
+
.replace(INLINE_CODE, stash)
|
|
263
|
+
|
|
264
|
+
const normalized = guarded
|
|
265
|
+
// Legacy blank-line markers stored a line of only NBSP (U+00A0). CommonMark
|
|
266
|
+
// treats NBSP as content — a lazy list continuation — re-introducing the
|
|
267
|
+
// list-indent bug, so migrate such lines to a real `<br>` marker on save.
|
|
268
|
+
// (A regular whitespace-only line is already a CommonMark blank line.)
|
|
269
|
+
.replace(/^[ \t]*\u00A0[ \t\u00A0]*$/gm, "<br>")
|
|
270
|
+
// Each blank-line marker is its own block: isolate every `<br>` with a blank
|
|
271
|
+
// line on both sides. Lexical's exporter joins a freshly-typed empty
|
|
272
|
+
// paragraph to its neighbours with a single `\n`, while a reopened (grouped)
|
|
273
|
+
// blank paragraph gets `\n\n`; canonicalizing here makes the very first save
|
|
274
|
+
// byte-identical to the reopen fixpoint regardless of which path produced it.
|
|
275
|
+
// BLANK_PARAGRAPH_TRANSFORMER always emits a marker alone on its line, so we
|
|
276
|
+
// only match a `<br>` that is the WHOLE line (anchored ^…$ under /m). A
|
|
277
|
+
// `<br>` embedded in other content (an in-paragraph hard break or raw inline
|
|
278
|
+
// HTML such as `<span>foo<br>bar</span>`) is not a marker and is left
|
|
279
|
+
// untouched — isolating it would rewrite user HTML on save.
|
|
280
|
+
.replace(/\n*^[ \t]*<br>[ \t]*$\n*/gm, "\n\n<br>\n\n")
|
|
281
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
282
|
+
.replace(/^\n+/, "")
|
|
283
|
+
.replace(/\s+$/, "")
|
|
284
|
+
|
|
285
|
+
return normalized.replace(/\x00MDGUARD(\d+)\x00/g, (_, n) => guards[Number(n)])
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// On reopen, a run of blank-line markers (<br>) comes back grouped into ONE
|
|
289
|
+
// paragraph holding N LineBreakNodes. Lexical renders such a paragraph as N+1
|
|
290
|
+
// visual lines — each break starts a new line on top of the paragraph's own
|
|
291
|
+
// line — so a single typed blank line reopens as TWO, growing by one on every
|
|
292
|
+
// reopen (the "blank lines keep multiplying" bug). Freshly typed blank lines are EMPTY
|
|
293
|
+
// paragraphs instead (zero children, one visual line each). Re-create that
|
|
294
|
+
// structure: replace every all-LineBreakNode paragraph with N empty paragraphs
|
|
295
|
+
// so reopened blank lines match typed ones. Export is unaffected — each empty
|
|
296
|
+
// paragraph still emits exactly one <br> (blankParagraphBreakCount), so the
|
|
297
|
+
// canonical Markdown round-trips byte-for-byte. Paragraphs that mix text with a
|
|
298
|
+
// break (real soft breaks) are left untouched.
|
|
299
|
+
export function splitBlankLineParagraphs(root) {
|
|
300
|
+
if (!root || !root.getChildren) return
|
|
301
|
+
root.getChildren().forEach((node) => {
|
|
302
|
+
if (!$isParagraphNode(node)) return
|
|
303
|
+
const children = node.getChildren()
|
|
304
|
+
if (children.length === 0) return
|
|
305
|
+
if (!children.every($isLineBreakNode)) return
|
|
306
|
+
for (let i = 0; i < children.length; i++) {
|
|
307
|
+
node.insertBefore($createParagraphNode())
|
|
308
|
+
}
|
|
309
|
+
node.remove()
|
|
310
|
+
})
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Read the editor state and return canonical Markdown.
|
|
314
|
+
export function lexicalToMarkdown(editor) {
|
|
315
|
+
let markdown = ""
|
|
316
|
+
editor.getEditorState().read(() => {
|
|
317
|
+
markdown = $convertToMarkdownString(MARKDOWN_TRANSFORMERS)
|
|
318
|
+
})
|
|
319
|
+
return normalizeMarkdownBlankLines(markdown)
|
|
320
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import {
|
|
2
|
+
$isRangeSelection,
|
|
3
|
+
$isTextNode,
|
|
4
|
+
$isRootOrShadowRoot
|
|
5
|
+
} from "lexical"
|
|
6
|
+
|
|
7
|
+
// True when a collapsed selection sits at the very start of the whole document
|
|
8
|
+
// (offset 0 of the first leaf, with no previous siblings up the tree).
|
|
9
|
+
//
|
|
10
|
+
// NOTE: checking `$getCharacterOffsets() === [0, 0]` is NOT enough — that only
|
|
11
|
+
// reports the offset within the current node, so the start of the *second*
|
|
12
|
+
// paragraph (e.g. right after pressing Enter) also reads as [0, 0]. We must walk
|
|
13
|
+
// up the tree confirming there is no earlier content, mirroring
|
|
14
|
+
// isSelectionAtDocumentEnd.
|
|
15
|
+
export function isSelectionAtDocumentStart(selection) {
|
|
16
|
+
if (!$isRangeSelection(selection) || !selection.isCollapsed()) return false
|
|
17
|
+
|
|
18
|
+
const anchor = selection.anchor
|
|
19
|
+
let node = anchor.getNode()
|
|
20
|
+
if (!node) return false
|
|
21
|
+
|
|
22
|
+
if (anchor.offset !== 0) return false
|
|
23
|
+
|
|
24
|
+
while (node && !$isRootOrShadowRoot(node)) {
|
|
25
|
+
if (node.getPreviousSibling()) return false
|
|
26
|
+
node = node.getParent()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return !!node && $isRootOrShadowRoot(node)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// True when a collapsed selection sits at the very end of the whole document
|
|
33
|
+
// (end of the last leaf, with no following siblings up the tree).
|
|
34
|
+
export function isSelectionAtDocumentEnd(selection) {
|
|
35
|
+
if (!$isRangeSelection(selection) || !selection.isCollapsed()) return false
|
|
36
|
+
|
|
37
|
+
const focus = selection.focus
|
|
38
|
+
let node = focus.getNode()
|
|
39
|
+
if (!node) return false
|
|
40
|
+
|
|
41
|
+
const offset = focus.offset
|
|
42
|
+
if ($isTextNode(node)) {
|
|
43
|
+
if (offset !== node.getTextContentSize()) return false
|
|
44
|
+
} else if (typeof node.getChildrenSize === "function") {
|
|
45
|
+
if (offset !== node.getChildrenSize()) return false
|
|
46
|
+
} else {
|
|
47
|
+
// Fallback for nodes without children size (e.g., line breaks)
|
|
48
|
+
const textSize = node.getTextContentSize?.() ?? 0
|
|
49
|
+
if (offset !== textSize) return false
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
while (node && !$isRootOrShadowRoot(node)) {
|
|
53
|
+
if (node.getNextSibling()) return false
|
|
54
|
+
node = node.getParent()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return !!node && $isRootOrShadowRoot(node)
|
|
58
|
+
}
|