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,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Collavre
|
|
4
|
+
module Api
|
|
5
|
+
module Authenticatable
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def extract_bearer_token
|
|
11
|
+
auth_header = request.headers["Authorization"]
|
|
12
|
+
return nil unless auth_header&.start_with?("Bearer ")
|
|
13
|
+
|
|
14
|
+
auth_header.sub("Bearer ", "")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Resolves the bearer token to a user via Doorkeeper, sets
|
|
18
|
+
# Collavre::Current.user, and returns the user. Returns nil (without
|
|
19
|
+
# rendering) when the token is blank/invalid/inaccessible or the user
|
|
20
|
+
# is missing — callers own the error response, because the two API
|
|
21
|
+
# engines render different error envelopes.
|
|
22
|
+
def find_user_by_bearer_token(token)
|
|
23
|
+
return nil if token.blank?
|
|
24
|
+
|
|
25
|
+
access_token = Doorkeeper::AccessToken.by_token(token)
|
|
26
|
+
return nil unless access_token&.accessible?
|
|
27
|
+
|
|
28
|
+
user = Collavre::User.find_by(id: access_token.resource_owner_id)
|
|
29
|
+
return nil unless user
|
|
30
|
+
|
|
31
|
+
Collavre::Current.user = user
|
|
32
|
+
user
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def current_user
|
|
36
|
+
Collavre::Current.user
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -58,6 +58,14 @@ module Collavre
|
|
|
58
58
|
end
|
|
59
59
|
end
|
|
60
60
|
|
|
61
|
+
def typo_correction
|
|
62
|
+
@user = Collavre::User.find(params[:id])
|
|
63
|
+
|
|
64
|
+
unless @user == Current.user || Current.user.system_admin?
|
|
65
|
+
redirect_to user_path(Current.user), alert: I18n.t("collavre.users.destroy.not_authorized")
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
61
69
|
def update_password
|
|
62
70
|
@user = Collavre::User.find(params[:id])
|
|
63
71
|
if @user.authenticate(params[:user][:current_password])
|
|
@@ -89,7 +97,14 @@ module Collavre
|
|
|
89
97
|
:notifications_enabled,
|
|
90
98
|
:calendar_id,
|
|
91
99
|
:timezone,
|
|
92
|
-
:locale
|
|
100
|
+
:locale,
|
|
101
|
+
:typo_correction_enabled,
|
|
102
|
+
:typo_correction_threshold,
|
|
103
|
+
:typo_correction_on_soft_keyboard,
|
|
104
|
+
:typo_correction_on_voice,
|
|
105
|
+
:typo_correction_on_physical_keyboard,
|
|
106
|
+
:typo_correction_in_chat,
|
|
107
|
+
:typo_correction_in_editor
|
|
93
108
|
).tap do |p|
|
|
94
109
|
p[:locale] = normalize_supported_locale(p[:locale]) if p.key?(:locale)
|
|
95
110
|
end
|
|
@@ -37,7 +37,34 @@ module Collavre
|
|
|
37
37
|
Collavre::Contact.ensure(user: invitation.inviter, contact_user: @user)
|
|
38
38
|
invitation.creative.create_linked_creative_for_user(@user)
|
|
39
39
|
end
|
|
40
|
-
|
|
40
|
+
# Deployments with no outbound mail (e.g. the local desktop app, which
|
|
41
|
+
# has no SMTP/SES) would otherwise leave the first user unable to verify
|
|
42
|
+
# and permanently locked out of login. Such envs opt into auto-verify.
|
|
43
|
+
# NB: compare to `true` explicitly — an unset config.x.* key reads back
|
|
44
|
+
# as a truthy empty OrderedOptions, so a bare `if` would fire everywhere.
|
|
45
|
+
if Rails.application.config.x.auto_verify_email == true
|
|
46
|
+
@user.update_column(:email_verified_at, Time.current)
|
|
47
|
+
else
|
|
48
|
+
Collavre::EmailVerificationMailer.verify(@user).deliver_later
|
|
49
|
+
end
|
|
50
|
+
# Envs that ship no seeded admin (e.g. desktop) bootstrap one here: the
|
|
51
|
+
# first registered user becomes system_admin. Guarded by "no admin yet"
|
|
52
|
+
# so only the very first signup is elevated, and by a loopback origin so
|
|
53
|
+
# that in documented open mode (LAN/Tailscale binding) a remote client
|
|
54
|
+
# cannot race the local owner for the admin account. `== true` for the
|
|
55
|
+
# same OrderedOptions reason as above.
|
|
56
|
+
if Rails.application.config.x.bootstrap_first_admin == true && request_from_loopback?
|
|
57
|
+
# Promote atomically rather than exists?-then-update: a separate check
|
|
58
|
+
# and write is a TOCTOU race where two concurrent first signups both
|
|
59
|
+
# read "no admin yet" and both elevate. A single UPDATE ... WHERE NOT
|
|
60
|
+
# EXISTS lets the DB evaluate the guard and write under one lock, so
|
|
61
|
+
# exactly one wins. This path only runs on desktop (the only env that
|
|
62
|
+
# sets bootstrap_first_admin), which is always SQLite — and SQLite
|
|
63
|
+
# serializes writers, so the loser's re-evaluated NOT EXISTS sees the
|
|
64
|
+
# admin and updates nothing.
|
|
65
|
+
admin_exists = Collavre::User.where(system_admin: true).arel.exists
|
|
66
|
+
Collavre::User.where(id: @user.id).where(admin_exists.not).update_all(system_admin: true)
|
|
67
|
+
end
|
|
41
68
|
session.delete(:return_to_after_authenticating)
|
|
42
69
|
redirect_to new_session_path, notice: I18n.t("collavre.users.new.success_sign_up")
|
|
43
70
|
else
|
|
@@ -56,6 +83,19 @@ module Collavre
|
|
|
56
83
|
|
|
57
84
|
private
|
|
58
85
|
|
|
86
|
+
# Whether the request originates from the local machine. Uses remote_addr
|
|
87
|
+
# (the socket peer) rather than remote_ip: remote_ip trusts X-Forwarded-For
|
|
88
|
+
# from private-range proxies, so a LAN attacker at e.g. 192.168.x could spoof
|
|
89
|
+
# `XFF: 127.0.0.1` and forge a loopback origin. The socket peer cannot be
|
|
90
|
+
# spoofed by headers.
|
|
91
|
+
def request_from_loopback?
|
|
92
|
+
addr = IPAddr.new(request.remote_addr.to_s)
|
|
93
|
+
addr = addr.native if addr.respond_to?(:ipv4_mapped?) && addr.ipv4_mapped?
|
|
94
|
+
addr.loopback?
|
|
95
|
+
rescue IPAddr::InvalidAddressError
|
|
96
|
+
false
|
|
97
|
+
end
|
|
98
|
+
|
|
59
99
|
def user_params
|
|
60
100
|
params.require(:user).permit(:email, :password, :password_confirmation, :name)
|
|
61
101
|
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Collavre
|
|
4
|
+
# Raised inside collavre-core API v1 controllers to produce the canonical
|
|
5
|
+
# single-message JSON envelope `{ error: "<message>" }` at a chosen status,
|
|
6
|
+
# via the rescue_from in Collavre::Api::V1::BaseController.
|
|
7
|
+
#
|
|
8
|
+
# NOTE: this is the collavre-core `{error: "str"}` shape only. It is NOT for
|
|
9
|
+
# the 422 validation-array shape `{errors: [...]}` (client retry contract),
|
|
10
|
+
# nor the OpenAI-nested shape in collavre_completion_api. Do not route those
|
|
11
|
+
# through this class.
|
|
12
|
+
class ApiError < StandardError
|
|
13
|
+
attr_reader :status
|
|
14
|
+
|
|
15
|
+
def initialize(message, status: :unprocessable_entity)
|
|
16
|
+
super(message)
|
|
17
|
+
@status = status
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -18,14 +18,25 @@ module Collavre
|
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
def render_creative_tags(creative)
|
|
21
|
-
|
|
21
|
+
# The browse tree preloads tags -> label per level; `includes` on an
|
|
22
|
+
# already-loaded association builds a fresh relation and re-queries it,
|
|
23
|
+
# which would reinstate the N+1 this is called from.
|
|
24
|
+
labels = if creative.tags.loaded?
|
|
25
|
+
creative.tags.map(&:label).compact
|
|
26
|
+
else
|
|
27
|
+
creative.tags&.includes(:label)&.map(&:label)&.compact
|
|
28
|
+
end
|
|
22
29
|
return "" if labels&.empty?
|
|
23
30
|
content_tag(:div, class: "creative-tags", style: "display: none;") do
|
|
24
31
|
render_tags(labels, "unstyled-link", true)
|
|
25
32
|
end
|
|
26
33
|
end
|
|
27
34
|
|
|
28
|
-
|
|
35
|
+
# `can_write`, `can_feedback` and `unread_count` let a caller rendering many
|
|
36
|
+
# creatives at once (the browse tree) resolve them in batch and hand them in.
|
|
37
|
+
# Left nil, each is resolved for this creative alone — correct, but a query
|
|
38
|
+
# per node. Single-creative call sites take that path.
|
|
39
|
+
def render_creative_progress(creative, select_mode: false, has_children: nil, can_write: nil, can_feedback: nil, unread_count: nil)
|
|
29
40
|
progress_value = if params[:tags].present?
|
|
30
41
|
tag_ids = Array(params[:tags]).map(&:to_s)
|
|
31
42
|
creative.filtered_progress || creative.progress_for_tags(tag_ids) || 0
|
|
@@ -33,15 +44,15 @@ module Collavre
|
|
|
33
44
|
creative.progress
|
|
34
45
|
end
|
|
35
46
|
|
|
47
|
+
can_feedback = creative.has_permission?(Current.user, :feedback) if can_feedback.nil?
|
|
48
|
+
|
|
36
49
|
content_tag(:div, class: "creative-row-end") do
|
|
37
50
|
comment_part = if creative.archived?
|
|
38
51
|
safe_join([])
|
|
39
|
-
elsif
|
|
52
|
+
elsif can_feedback
|
|
40
53
|
origin = creative.effective_origin
|
|
41
|
-
comments_count = origin.
|
|
42
|
-
|
|
43
|
-
last_read_id = pointer&.last_read_comment_id
|
|
44
|
-
unread_count = last_read_id ? origin.comments.where("id > ? and private = ?", last_read_id, false).count : comments_count
|
|
54
|
+
comments_count = origin.comments_count
|
|
55
|
+
unread_count = unread_count_for(origin, comments_count) if unread_count.nil?
|
|
45
56
|
if Current.user && CommentPresenceStore.list(origin.id).include?(Current.user.id)
|
|
46
57
|
unread_count = 0
|
|
47
58
|
end
|
|
@@ -70,7 +81,7 @@ module Collavre
|
|
|
70
81
|
safe_join([])
|
|
71
82
|
end
|
|
72
83
|
is_leaf = has_children.nil? ? !creative.children.exists? : !has_children
|
|
73
|
-
can_write = creative.has_permission?(Current.user, :write)
|
|
84
|
+
can_write = creative.has_permission?(Current.user, :write) if can_write.nil?
|
|
74
85
|
progress_part = if is_leaf && can_write && !select_mode
|
|
75
86
|
render_progress_toggle(creative, progress_value)
|
|
76
87
|
else
|
|
@@ -86,6 +97,17 @@ module Collavre
|
|
|
86
97
|
end
|
|
87
98
|
end
|
|
88
99
|
|
|
100
|
+
# Unread comments on `origin` for the current user. With no read pointer,
|
|
101
|
+
# every comment is unread. Batched by CommentBadgeIndex for the browse tree;
|
|
102
|
+
# this is the single-creative path.
|
|
103
|
+
def unread_count_for(origin, comments_count)
|
|
104
|
+
pointer = CommentReadPointer.find_by(user: Current.user, creative: origin)
|
|
105
|
+
last_read_id = pointer&.last_read_comment_id
|
|
106
|
+
return comments_count unless last_read_id
|
|
107
|
+
|
|
108
|
+
origin.comments.where("id > ? and private = ?", last_read_id, false).count
|
|
109
|
+
end
|
|
110
|
+
|
|
89
111
|
def render_progress_toggle(creative, value)
|
|
90
112
|
complete = value == 1
|
|
91
113
|
new_value = complete ? 0 : 1
|
data/app/javascript/collavre.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import "./modules/creatives"
|
|
6
6
|
import "./modules/creative_row_swipe"
|
|
7
7
|
import "./modules/mention_menu"
|
|
8
|
+
import "./modules/typo_corrector"
|
|
8
9
|
import "./modules/command_menu"
|
|
9
10
|
import "./modules/modal_dialog"
|
|
10
11
|
import "./modules/export_to_markdown"
|
|
@@ -19,6 +20,7 @@ import "./components/creative_tree_row"
|
|
|
19
20
|
// Import and re-export lib utilities
|
|
20
21
|
import "./lib/apply_lexical_styles"
|
|
21
22
|
import "./lib/turbo_stream_actions"
|
|
23
|
+
import "./lib/turbo_confirm"
|
|
22
24
|
|
|
23
25
|
// Export controller registration
|
|
24
26
|
export { registerControllers } from "./controllers"
|
|
@@ -51,9 +51,15 @@ export default function ImageResizer({ src, altText, width, height, nodeKey }) {
|
|
|
51
51
|
editor.registerCommand(
|
|
52
52
|
CLICK_COMMAND,
|
|
53
53
|
(event) => {
|
|
54
|
-
// If click is outside our image, deselect
|
|
54
|
+
// If click is outside our image, deselect — but only via clearSelection.
|
|
55
|
+
// setSelected(false) is destructive here: when the click lands in text
|
|
56
|
+
// (a RangeSelection / the caret), the hook synthesizes a fresh empty
|
|
57
|
+
// NodeSelection and replaces the caret with it, so the editor loses its
|
|
58
|
+
// caret and becomes uneditable the instant you click to edit while an
|
|
59
|
+
// image is present. clearSelection only acts on an existing
|
|
60
|
+
// NodeSelection, so a RangeSelection (caret) is preserved.
|
|
55
61
|
if (containerRef.current && !containerRef.current.contains(event.target)) {
|
|
56
|
-
|
|
62
|
+
clearSelection()
|
|
57
63
|
}
|
|
58
64
|
return false
|
|
59
65
|
},
|
|
@@ -90,7 +96,7 @@ export default function ImageResizer({ src, altText, width, height, nodeKey }) {
|
|
|
90
96
|
COMMAND_PRIORITY_LOW
|
|
91
97
|
)
|
|
92
98
|
)
|
|
93
|
-
}, [editor, isSelected, nodeKey,
|
|
99
|
+
}, [editor, isSelected, nodeKey, clearSelection])
|
|
94
100
|
|
|
95
101
|
// Resize logic
|
|
96
102
|
const onPointerDown = useCallback(
|
|
@@ -15,12 +15,14 @@ import { HeadingNode, QuoteNode, $isHeadingNode, $isQuoteNode } from "@lexical/r
|
|
|
15
15
|
import {
|
|
16
16
|
CodeNode,
|
|
17
17
|
CodeHighlightNode,
|
|
18
|
-
$createCodeNode,
|
|
19
18
|
$isCodeNode,
|
|
20
19
|
registerCodeHighlighting
|
|
21
20
|
} from "@lexical/code"
|
|
22
21
|
import { ListItemNode, ListNode, $isListItemNode, $isListNode, INSERT_ORDERED_LIST_COMMAND, INSERT_UNORDERED_LIST_COMMAND } from "@lexical/list"
|
|
23
22
|
import { $createLinkNode, LinkNode, AutoLinkNode, TOGGLE_LINK_COMMAND } from "@lexical/link"
|
|
23
|
+
import { TableNode, TableRowNode, TableCellNode, INSERT_TABLE_COMMAND } from "@lexical/table"
|
|
24
|
+
import { TablePlugin } from "@lexical/react/LexicalTablePlugin"
|
|
25
|
+
import TableHoverActionsPlugin from "./plugins/table_hover_actions_plugin"
|
|
24
26
|
import {
|
|
25
27
|
$createParagraphNode,
|
|
26
28
|
$createTextNode,
|
|
@@ -51,10 +53,21 @@ import { AttachmentNode } from "../lib/lexical/attachment_node"
|
|
|
51
53
|
import { VideoNode } from "../lib/lexical/video_node"
|
|
52
54
|
import AttachmentCleanupPlugin from "./plugins/attachment_cleanup_plugin"
|
|
53
55
|
import MarkdownShortcutsPlugin from "./plugins/markdown_shortcuts_plugin"
|
|
56
|
+
import ListTabIndentPlugin from "./plugins/list_tab_indent_plugin"
|
|
54
57
|
import { syncLexicalStyleAttributes } from "../lib/lexical/style_attributes"
|
|
58
|
+
import { $toggleCodeBlockForSelection } from "../lib/lexical/code_block_toggle"
|
|
55
59
|
import { lexicalHtmlConfig, normalizeColoredContainers } from "../lib/lexical/color_import"
|
|
56
60
|
import { minimizeContentHtml } from "../lib/lexical/minimize_html"
|
|
61
|
+
import { ensureTrailingParagraph, registerTrailingParagraph } from "../lib/lexical/trailing_paragraph"
|
|
62
|
+
import {
|
|
63
|
+
MARKDOWN_TRANSFORMERS,
|
|
64
|
+
normalizeMarkdownBlankLines,
|
|
65
|
+
splitBlankLineParagraphs
|
|
66
|
+
} from "../lib/lexical/markdown_serialize"
|
|
67
|
+
import { $convertToMarkdownString } from "@lexical/markdown"
|
|
57
68
|
import { updateResponsiveImages } from "../lib/responsive_images"
|
|
69
|
+
import { CODE_TOKEN_THEME } from "../lib/editor/code_token_theme"
|
|
70
|
+
import { detectCodeLanguage, normalizeFenceLang, bridgeCodeFenceLanguages, markLanguageResolved, isLanguageResolved, clearLanguageResolved } from "../lib/editor/code_languages"
|
|
58
71
|
|
|
59
72
|
const URL_MATCHERS = [
|
|
60
73
|
createLinkMatcherWithRegExp(/https?:\/\/[^\s<]+/gi, (text) => text)
|
|
@@ -71,40 +84,14 @@ const theme = {
|
|
|
71
84
|
list: {
|
|
72
85
|
ul: "lexical-list-ul",
|
|
73
86
|
ol: "lexical-list-ol",
|
|
74
|
-
listitem: "lexical-list-item"
|
|
87
|
+
listitem: "lexical-list-item",
|
|
88
|
+
// Tag the wrapper <li> that only holds a nested list so its bullet marker
|
|
89
|
+
// can be hidden — without this Lexical reuses the plain item class and the
|
|
90
|
+
// empty wrapper renders a stray bullet above the indented sub-list.
|
|
91
|
+
nested: { listitem: "lexical-nested-list-item" }
|
|
75
92
|
},
|
|
76
93
|
code: "lexical-code-block",
|
|
77
|
-
codeHighlight:
|
|
78
|
-
atrule: "lexical-token-atrule",
|
|
79
|
-
attr: "lexical-token-attr",
|
|
80
|
-
boolean: "lexical-token-boolean",
|
|
81
|
-
builtin: "lexical-token-builtin",
|
|
82
|
-
cdata: "lexical-token-cdata",
|
|
83
|
-
char: "lexical-token-char",
|
|
84
|
-
class: "lexical-token-class",
|
|
85
|
-
comment: "lexical-token-comment",
|
|
86
|
-
constant: "lexical-token-constant",
|
|
87
|
-
deleted: "lexical-token-deleted",
|
|
88
|
-
doctype: "lexical-token-doctype",
|
|
89
|
-
entity: "lexical-token-entity",
|
|
90
|
-
function: "lexical-token-function",
|
|
91
|
-
important: "lexical-token-important",
|
|
92
|
-
inserted: "lexical-token-inserted",
|
|
93
|
-
keyword: "lexical-token-keyword",
|
|
94
|
-
namespace: "lexical-token-namespace",
|
|
95
|
-
number: "lexical-token-number",
|
|
96
|
-
operator: "lexical-token-operator",
|
|
97
|
-
prolog: "lexical-token-prolog",
|
|
98
|
-
property: "lexical-token-property",
|
|
99
|
-
punctuation: "lexical-token-punctuation",
|
|
100
|
-
regex: "lexical-token-regex",
|
|
101
|
-
selector: "lexical-token-selector",
|
|
102
|
-
string: "lexical-token-string",
|
|
103
|
-
symbol: "lexical-token-symbol",
|
|
104
|
-
tag: "lexical-token-tag",
|
|
105
|
-
url: "lexical-token-url",
|
|
106
|
-
variable: "lexical-token-variable"
|
|
107
|
-
},
|
|
94
|
+
codeHighlight: CODE_TOKEN_THEME,
|
|
108
95
|
link: "lexical-link",
|
|
109
96
|
text: {
|
|
110
97
|
bold: "lexical-text-bold",
|
|
@@ -112,7 +99,18 @@ const theme = {
|
|
|
112
99
|
underline: "lexical-text-underline",
|
|
113
100
|
strikethrough: "lexical-text-strike",
|
|
114
101
|
code: "lexical-text-code"
|
|
115
|
-
}
|
|
102
|
+
},
|
|
103
|
+
table: "lexical-table",
|
|
104
|
+
tableScrollableWrapper: "lexical-table-wrapper",
|
|
105
|
+
tableRow: "lexical-table-row",
|
|
106
|
+
tableCell: "lexical-table-cell",
|
|
107
|
+
tableCellHeader: "lexical-table-cell-header",
|
|
108
|
+
tableSelected: "lexical-table-selected",
|
|
109
|
+
tableSelection: "lexical-table-selection",
|
|
110
|
+
tableAddRows: "lexical-table-add-rows",
|
|
111
|
+
tableAddColumns: "lexical-table-add-columns",
|
|
112
|
+
tableDeleteRows: "lexical-table-delete-rows",
|
|
113
|
+
tableDeleteColumns: "lexical-table-delete-columns"
|
|
116
114
|
}
|
|
117
115
|
|
|
118
116
|
function Placeholder({ text }) {
|
|
@@ -129,6 +127,9 @@ function InitialContentPlugin({ html }) {
|
|
|
129
127
|
lastApplied.current = html
|
|
130
128
|
editor.update(() => {
|
|
131
129
|
const root = $getRoot()
|
|
130
|
+
// Re-importing replaces the tree; drop stale resolved-language keys so the
|
|
131
|
+
// registry only tracks nodes from this import.
|
|
132
|
+
clearLanguageResolved(editor)
|
|
132
133
|
// Explicitly remove all children to ensure it's empty
|
|
133
134
|
root.getChildren().forEach((child) => child.remove())
|
|
134
135
|
|
|
@@ -137,6 +138,15 @@ function InitialContentPlugin({ html }) {
|
|
|
137
138
|
// No more .trix-content wrapper
|
|
138
139
|
const container = doc.body
|
|
139
140
|
|
|
141
|
+
// @lexical/code's importer only reads `data-language`, but the language is
|
|
142
|
+
// encoded differently depending on which renderer produced this HTML:
|
|
143
|
+
// commonmarker (server reopen) uses `<pre lang>`, while renderMarkdown (the
|
|
144
|
+
// markdown→rich toggle) puts it on `<pre><code class="language-X">`. Bridge
|
|
145
|
+
// both onto `data-language` so an explicit fence language survives reopen
|
|
146
|
+
// instead of being dropped (and then defaulted to javascript). Detection
|
|
147
|
+
// still corrects unlabeled blocks.
|
|
148
|
+
bridgeCodeFenceLanguages(container)
|
|
149
|
+
|
|
140
150
|
// Color / background-color are bound to text nodes during import by the
|
|
141
151
|
// colorAwareSpanImport html config (see lib/lexical/color_import). We no
|
|
142
152
|
// longer re-apply styles positionally after import, which used to drift
|
|
@@ -148,6 +158,23 @@ function InitialContentPlugin({ html }) {
|
|
|
148
158
|
normalizeColoredContainers(container)
|
|
149
159
|
const nodes = $generateNodesFromDOM(editor, container)
|
|
150
160
|
|
|
161
|
+
// Mark code blocks whose language came from an explicit source label as
|
|
162
|
+
// resolved BEFORE registerCodeHighlighting bakes the "javascript" default
|
|
163
|
+
// onto unlabeled ones. At this point a non-empty language can only be one
|
|
164
|
+
// the bridge set from a real fence/attribute, so the detection transform
|
|
165
|
+
// will honor it verbatim (incl. an explicit "javascript") and only
|
|
166
|
+
// re-detect the still-unlabeled blocks.
|
|
167
|
+
const markExplicitCodeLanguages = (list) => {
|
|
168
|
+
list.forEach((node) => {
|
|
169
|
+
if ($isCodeNode(node)) {
|
|
170
|
+
if (node.getLanguage()) markLanguageResolved(editor, node.getKey())
|
|
171
|
+
} else if ($isElementNode(node) && typeof node.getChildren === "function") {
|
|
172
|
+
markExplicitCodeLanguages(node.getChildren())
|
|
173
|
+
}
|
|
174
|
+
})
|
|
175
|
+
}
|
|
176
|
+
markExplicitCodeLanguages(nodes)
|
|
177
|
+
|
|
151
178
|
// Filter out duplicate image nodes if any
|
|
152
179
|
const uniqueNodes = []
|
|
153
180
|
const seenImages = new Set()
|
|
@@ -215,12 +242,34 @@ function InitialContentPlugin({ html }) {
|
|
|
215
242
|
if (root.getChildrenSize() === 0) {
|
|
216
243
|
root.append($createParagraphNode())
|
|
217
244
|
}
|
|
245
|
+
|
|
246
|
+
// A blank line the user typed is an empty paragraph, but the server renders
|
|
247
|
+
// it as a standalone <br> that re-imports as a paragraph holding a
|
|
248
|
+
// LineBreakNode — which Lexical draws as TWO lines, so blank lines grew by
|
|
249
|
+
// one on every reopen. Split those marker paragraphs back into empty
|
|
250
|
+
// paragraphs so reopened blank lines match freshly typed ones. Runs AFTER
|
|
251
|
+
// the trailing-empty cleanup above so an intentional trailing blank line
|
|
252
|
+
// (a marker paragraph the cleanup leaves alone) is preserved, not stripped.
|
|
253
|
+
splitBlankLineParagraphs(root)
|
|
254
|
+
|
|
255
|
+
// A trailing top-level image/video/attachment leaves no caret position
|
|
256
|
+
// after it, making the document uneditable. Guarantee an editable
|
|
257
|
+
// paragraph after it. (The RootNode transform below keeps this true while
|
|
258
|
+
// editing; this handles the initial load deterministically regardless of
|
|
259
|
+
// plugin effect ordering.)
|
|
260
|
+
ensureTrailingParagraph(root)
|
|
218
261
|
})
|
|
219
262
|
}, [editor, html])
|
|
220
263
|
|
|
221
264
|
return null
|
|
222
265
|
}
|
|
223
266
|
|
|
267
|
+
function TrailingParagraphPlugin() {
|
|
268
|
+
const [editor] = useLexicalComposerContext()
|
|
269
|
+
useEffect(() => registerTrailingParagraph(editor), [editor])
|
|
270
|
+
return null
|
|
271
|
+
}
|
|
272
|
+
|
|
224
273
|
function LinkAttributesPlugin() {
|
|
225
274
|
const [editor] = useLexicalComposerContext()
|
|
226
275
|
|
|
@@ -247,7 +296,34 @@ function CodeHighlightingPlugin() {
|
|
|
247
296
|
const [editor] = useLexicalComposerContext()
|
|
248
297
|
|
|
249
298
|
useEffect(() => {
|
|
250
|
-
|
|
299
|
+
const unregisterHighlight = registerCodeHighlighting(editor)
|
|
300
|
+
|
|
301
|
+
// registerCodeHighlighting bakes "javascript" onto any code block without a
|
|
302
|
+
// language (its tokenizer default), which then serializes into the canonical
|
|
303
|
+
// markdown as ```javascript — so Ruby/Python/etc. blocks get permanently
|
|
304
|
+
// mislabeled on the first edit. This transform re-detects the real language
|
|
305
|
+
// from the block's content whenever it's unconfirmed (missing or stuck on
|
|
306
|
+
// the javascript default) and corrects the node, so the editor shows — and
|
|
307
|
+
// saves — the right language. An explicit non-default language is left alone.
|
|
308
|
+
const unregisterDetect = editor.registerNodeTransform(CodeNode, (node) => {
|
|
309
|
+
// A language that came from an explicit source label on import is honored
|
|
310
|
+
// verbatim — including "javascript" — so auto-detection never overrides a
|
|
311
|
+
// deliberate choice. Only unlabeled/new blocks (baked to the javascript
|
|
312
|
+
// default) are re-detected from their content.
|
|
313
|
+
if (isLanguageResolved(editor, node.getKey())) return
|
|
314
|
+
const current = node.getLanguage()
|
|
315
|
+
const norm = normalizeFenceLang(current)
|
|
316
|
+
if (norm && norm !== "javascript") return
|
|
317
|
+
const detected = detectCodeLanguage(node.getTextContent(), current)
|
|
318
|
+
if (detected && detected !== "javascript" && detected !== current) {
|
|
319
|
+
node.setLanguage(detected)
|
|
320
|
+
}
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
return () => {
|
|
324
|
+
unregisterHighlight()
|
|
325
|
+
unregisterDetect()
|
|
326
|
+
}
|
|
251
327
|
}, [editor])
|
|
252
328
|
|
|
253
329
|
return null
|
|
@@ -474,6 +550,14 @@ function Toolbar() {
|
|
|
474
550
|
[editor]
|
|
475
551
|
)
|
|
476
552
|
|
|
553
|
+
const insertTable = useCallback(() => {
|
|
554
|
+
editor.dispatchCommand(INSERT_TABLE_COMMAND, {
|
|
555
|
+
columns: "3",
|
|
556
|
+
rows: "3",
|
|
557
|
+
includeHeaders: true
|
|
558
|
+
})
|
|
559
|
+
}, [editor])
|
|
560
|
+
|
|
477
561
|
const toggleLink = useCallback(() => {
|
|
478
562
|
let hasLink = false
|
|
479
563
|
let selectionText = ""
|
|
@@ -598,39 +682,7 @@ function Toolbar() {
|
|
|
598
682
|
|
|
599
683
|
const toggleCodeBlock = useCallback(() => {
|
|
600
684
|
editor.update(() => {
|
|
601
|
-
|
|
602
|
-
if (!$isRangeSelection(selection)) return
|
|
603
|
-
|
|
604
|
-
const anchorNode = selection.anchor.getNode()
|
|
605
|
-
const topLevel = anchorNode.getTopLevelElement()
|
|
606
|
-
if (!topLevel) return
|
|
607
|
-
|
|
608
|
-
if ($isCodeNode(topLevel)) {
|
|
609
|
-
const textContent = topLevel.getTextContent()
|
|
610
|
-
const lines = textContent.split("\n")
|
|
611
|
-
const firstParagraph = $createParagraphNode()
|
|
612
|
-
firstParagraph.append($createTextNode(lines[0] || ""))
|
|
613
|
-
topLevel.replace(firstParagraph)
|
|
614
|
-
let previous = firstParagraph
|
|
615
|
-
for (let index = 1; index < lines.length; index += 1) {
|
|
616
|
-
const paragraph = $createParagraphNode()
|
|
617
|
-
paragraph.append($createTextNode(lines[index]))
|
|
618
|
-
previous.insertAfter(paragraph)
|
|
619
|
-
previous = paragraph
|
|
620
|
-
}
|
|
621
|
-
firstParagraph.selectEnd()
|
|
622
|
-
return
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
if (topLevel.getType?.() !== "paragraph") {
|
|
626
|
-
return
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
const codeNode = $createCodeNode()
|
|
630
|
-
const content = topLevel.getTextContent()
|
|
631
|
-
codeNode.append($createTextNode(content || ""))
|
|
632
|
-
topLevel.replace(codeNode)
|
|
633
|
-
codeNode.selectEnd()
|
|
685
|
+
$toggleCodeBlockForSelection()
|
|
634
686
|
})
|
|
635
687
|
}, [editor])
|
|
636
688
|
|
|
@@ -712,6 +764,14 @@ function Toolbar() {
|
|
|
712
764
|
title="Numbered list">
|
|
713
765
|
1.
|
|
714
766
|
</button>
|
|
767
|
+
<button
|
|
768
|
+
type="button"
|
|
769
|
+
className="lexical-toolbar-btn"
|
|
770
|
+
onClick={insertTable}
|
|
771
|
+
title="Insert table"
|
|
772
|
+
aria-label="Insert table">
|
|
773
|
+
▦
|
|
774
|
+
</button>
|
|
715
775
|
<span className="lexical-toolbar-separator" aria-hidden="true" />
|
|
716
776
|
<button
|
|
717
777
|
type="button"
|
|
@@ -872,6 +932,16 @@ function EditorInner({
|
|
|
872
932
|
}) {
|
|
873
933
|
const [editor] = useLexicalComposerContext()
|
|
874
934
|
|
|
935
|
+
// Anchor for the floating table plugins (hover "+" and the cell action menu).
|
|
936
|
+
// Portaling into the editor's own subtree (not document.body) ties the floating
|
|
937
|
+
// UI's lifetime to the editor DOM: when the editor is torn down — including
|
|
938
|
+
// Turbo/host teardown that removes the container without a React unmount — the
|
|
939
|
+
// chevron button is removed with it instead of being orphaned in document.body.
|
|
940
|
+
const [floatingAnchorElem, setFloatingAnchorElem] = useState(null)
|
|
941
|
+
const onAnchorRef = useCallback((el) => {
|
|
942
|
+
if (el !== null) setFloatingAnchorElem(el)
|
|
943
|
+
}, [])
|
|
944
|
+
|
|
875
945
|
// File drop is handled by FileUploadPlugin's DROP_COMMAND handler.
|
|
876
946
|
// We only need dragOver to allow the browser to accept file drops.
|
|
877
947
|
const handleDragOver = useCallback((event) => {
|
|
@@ -883,7 +953,7 @@ function EditorInner({
|
|
|
883
953
|
return (
|
|
884
954
|
<div className="lexical-editor-shell">
|
|
885
955
|
<Toolbar />
|
|
886
|
-
<div className="lexical-editor-inner">
|
|
956
|
+
<div className="lexical-editor-inner" ref={onAnchorRef}>
|
|
887
957
|
<RichTextPlugin
|
|
888
958
|
contentEditable={
|
|
889
959
|
<ContentEditable
|
|
@@ -901,12 +971,18 @@ function EditorInner({
|
|
|
901
971
|
<HistoryPlugin />
|
|
902
972
|
<CodeHighlightingPlugin />
|
|
903
973
|
<ListPlugin />
|
|
974
|
+
<TablePlugin hasCellMerge={false} hasCellBackgroundColor={false} />
|
|
975
|
+
{floatingAnchorElem && (
|
|
976
|
+
<TableHoverActionsPlugin anchorElem={floatingAnchorElem} />
|
|
977
|
+
)}
|
|
978
|
+
<ListTabIndentPlugin />
|
|
904
979
|
<LinkPlugin />
|
|
905
980
|
<AutoLinkPlugin matchers={URL_MATCHERS} />
|
|
906
981
|
<OnChangePlugin
|
|
907
982
|
onChange={(editorState, editorInstance) => {
|
|
908
983
|
if (!onChange) return
|
|
909
984
|
let serialized = ""
|
|
985
|
+
let markdown = ""
|
|
910
986
|
editorState.read(() => {
|
|
911
987
|
const innerHtml = $generateHtmlFromNodes(editorInstance)
|
|
912
988
|
const parser = new DOMParser()
|
|
@@ -924,11 +1000,17 @@ function EditorInner({
|
|
|
924
1000
|
// Strip Lexical's verbose markup (extra <div>, white-space spans,
|
|
925
1001
|
// duplicate format wrappers, single-line <p>) before persisting.
|
|
926
1002
|
serialized = minimizeContentHtml(doc.body.firstElementChild)
|
|
1003
|
+
// Canonical Markdown projection (color/bg -> normalized <span>).
|
|
1004
|
+
// normalizeMarkdownBlankLines keeps the standard `\n\n` paragraph
|
|
1005
|
+
// separation (Enter = real paragraph break) and only tidies blank-
|
|
1006
|
+
// line runs / the empty-state — no marker characters in the source.
|
|
1007
|
+
markdown = normalizeMarkdownBlankLines($convertToMarkdownString(MARKDOWN_TRANSFORMERS))
|
|
927
1008
|
})
|
|
928
|
-
//
|
|
929
|
-
onChange(serialized)
|
|
1009
|
+
// html: client-side preview/fallback; markdown: canonical storage.
|
|
1010
|
+
onChange({ html: serialized, markdown })
|
|
930
1011
|
}}
|
|
931
1012
|
/>
|
|
1013
|
+
<TrailingParagraphPlugin />
|
|
932
1014
|
<InitialContentPlugin html={initialHtml} />
|
|
933
1015
|
<LinkAttributesPlugin />
|
|
934
1016
|
<ReadyPlugin onReady={onReady} />
|
|
@@ -1001,7 +1083,10 @@ export default function InlineLexicalEditor({
|
|
|
1001
1083
|
AutoLinkNode,
|
|
1002
1084
|
ImageNode,
|
|
1003
1085
|
AttachmentNode,
|
|
1004
|
-
VideoNode
|
|
1086
|
+
VideoNode,
|
|
1087
|
+
TableNode,
|
|
1088
|
+
TableRowNode,
|
|
1089
|
+
TableCellNode
|
|
1005
1090
|
],
|
|
1006
1091
|
onError(error) {
|
|
1007
1092
|
throw error
|