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.
Files changed (221) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -0
  3. data/app/assets/images/collavre/landing/interface-en-dark.png +0 -0
  4. data/app/assets/images/collavre/landing/interface-en.png +0 -0
  5. data/app/assets/images/collavre/landing/interface-ko-dark.png +0 -0
  6. data/app/assets/images/collavre/landing/interface-ko.png +0 -0
  7. data/app/assets/stylesheets/collavre/actiontext.css +251 -90
  8. data/app/assets/stylesheets/collavre/code_highlight.css +7 -201
  9. data/app/assets/stylesheets/collavre/comments_popup.css +158 -61
  10. data/app/assets/stylesheets/collavre/creatives.css +11 -2
  11. data/app/assets/stylesheets/collavre/landing.css +92 -0
  12. data/app/assets/stylesheets/collavre/modal_dialog.css +32 -0
  13. data/app/assets/stylesheets/collavre/popup.css +4 -2
  14. data/app/assets/stylesheets/collavre/tables.css +91 -0
  15. data/app/channels/collavre/inbox_badge_channel.rb +30 -0
  16. data/app/controllers/collavre/admin/settings_controller.rb +52 -67
  17. data/app/controllers/collavre/api/v1/agents_controller.rb +17 -188
  18. data/app/controllers/collavre/api/v1/base_controller.rb +9 -27
  19. data/app/controllers/collavre/api/v1/mobile/agent_events_controller.rb +224 -0
  20. data/app/controllers/collavre/api/v1/mobile/base_controller.rb +95 -0
  21. data/app/controllers/collavre/api/v1/mobile/devices_controller.rb +31 -0
  22. data/app/controllers/collavre/api/v1/mobile/voice_commands_controller.rb +25 -0
  23. data/app/controllers/collavre/attachments_controller.rb +13 -3
  24. data/app/controllers/collavre/concerns/slide_viewable.rb +8 -32
  25. data/app/controllers/collavre/creatives_controller.rb +47 -103
  26. data/app/controllers/collavre/inbox_items_controller.rb +10 -4
  27. data/app/controllers/collavre/landing_controller.rb +5 -2
  28. data/app/controllers/collavre/tasks_controller.rb +13 -4
  29. data/app/controllers/collavre/topics_controller.rb +54 -1
  30. data/app/controllers/collavre/typo_corrections_controller.rb +39 -0
  31. data/app/controllers/concerns/collavre/api/authenticatable.rb +40 -0
  32. data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +16 -1
  33. data/app/controllers/concerns/collavre/users_controller/registration.rb +41 -1
  34. data/app/errors/collavre/api_error.rb +20 -0
  35. data/app/helpers/collavre/application_helper.rb +1 -0
  36. data/app/helpers/collavre/creatives_helper.rb +30 -8
  37. data/app/javascript/collavre.js +2 -0
  38. data/app/javascript/components/ImageResizer.jsx +9 -3
  39. data/app/javascript/components/InlineLexicalEditor.jsx +157 -72
  40. data/app/javascript/components/__tests__/creative_tree_row_parent_id.test.js +87 -0
  41. data/app/javascript/components/creative_tree_row.js +28 -5
  42. data/app/javascript/components/plugins/list_tab_indent_plugin.jsx +16 -0
  43. data/app/javascript/components/plugins/markdown_shortcuts_plugin.jsx +15 -4
  44. data/app/javascript/components/plugins/table_hover_actions_plugin.jsx +405 -0
  45. data/app/javascript/controllers/__tests__/inbox_badge_controller.test.js +73 -0
  46. data/app/javascript/controllers/__tests__/search_popup_controller.test.js +104 -0
  47. data/app/javascript/controllers/__tests__/topic_list_controller.test.js +82 -0
  48. data/app/javascript/controllers/comment_controller.js +5 -4
  49. data/app/javascript/controllers/comment_version_controller.js +2 -1
  50. data/app/javascript/controllers/comments/__tests__/form_controller_double_submit.test.js +159 -0
  51. data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +3 -2
  52. data/app/javascript/controllers/comments/__tests__/topics_controller_delete.test.js +94 -0
  53. data/app/javascript/controllers/comments/__tests__/topics_controller_topic_list.test.js +61 -0
  54. data/app/javascript/controllers/comments/form_controller.js +21 -5
  55. data/app/javascript/controllers/comments/list_controller.js +18 -17
  56. data/app/javascript/controllers/comments/presence_controller.js +2 -1
  57. data/app/javascript/controllers/comments/topics_controller.js +61 -8
  58. data/app/javascript/controllers/common_popup_controller.js +2 -2
  59. data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +150 -0
  60. data/app/javascript/controllers/creatives/import_controller.js +2 -1
  61. data/app/javascript/controllers/creatives/select_mode_controller.js +2 -1
  62. data/app/javascript/controllers/creatives/tree_controller.js +142 -1
  63. data/app/javascript/controllers/image_lightbox_controller.js +2 -1
  64. data/app/javascript/controllers/inbox_badge_controller.js +33 -0
  65. data/app/javascript/controllers/index.js +7 -1
  66. data/app/javascript/controllers/search_popup_controller.js +17 -1
  67. data/app/javascript/controllers/share_modal_controller.js +4 -3
  68. data/app/javascript/controllers/topic_list_controller.js +78 -0
  69. data/app/javascript/controllers/topic_search_controller.js +2 -1
  70. data/app/javascript/creatives/drag_drop/event_handlers.js +14 -5
  71. data/app/javascript/creatives/topic_move_members_popup.js +156 -0
  72. data/app/javascript/creatives/tree_renderer.js +11 -0
  73. data/app/javascript/lib/__tests__/common_popup_bounds.test.js +58 -0
  74. data/app/javascript/lib/__tests__/turbo_confirm.test.js +81 -0
  75. data/app/javascript/lib/__tests__/typo_correction.test.js +192 -0
  76. data/app/javascript/lib/api/__tests__/api_error.test.js +96 -0
  77. data/app/javascript/lib/api/__tests__/queue_manager.test.js +88 -1
  78. data/app/javascript/lib/api/api_error.js +108 -0
  79. data/app/javascript/lib/api/queue_manager.js +38 -4
  80. data/app/javascript/lib/common_popup.js +42 -10
  81. data/app/javascript/lib/editor/__tests__/code_edit_view_token_parity.test.js +121 -0
  82. data/app/javascript/lib/editor/__tests__/code_language_roundtrip.test.js +152 -0
  83. data/app/javascript/lib/editor/__tests__/code_languages.test.js +93 -0
  84. data/app/javascript/lib/editor/code_languages.js +173 -0
  85. data/app/javascript/lib/editor/code_token_theme.js +41 -0
  86. data/app/javascript/lib/lexical/__tests__/code_block_toggle.test.js +326 -0
  87. data/app/javascript/lib/lexical/__tests__/code_fence_shortcut.test.js +179 -0
  88. data/app/javascript/lib/lexical/__tests__/image_focus.test.js +139 -0
  89. data/app/javascript/lib/lexical/__tests__/list_tab_indent.test.js +633 -0
  90. data/app/javascript/lib/lexical/__tests__/markdown_serialize.test.js +627 -0
  91. data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +20 -1
  92. data/app/javascript/lib/lexical/__tests__/selection_boundary.test.js +88 -0
  93. data/app/javascript/lib/lexical/__tests__/table_transformer.test.js +163 -0
  94. data/app/javascript/lib/lexical/__tests__/trailing_paragraph.test.js +104 -0
  95. data/app/javascript/lib/lexical/code_block_toggle.js +130 -0
  96. data/app/javascript/lib/lexical/code_fence_shortcut.js +60 -0
  97. data/app/javascript/lib/lexical/list_tab_indent.js +210 -0
  98. data/app/javascript/lib/lexical/markdown_serialize.js +320 -0
  99. data/app/javascript/lib/lexical/selection_boundary.js +58 -0
  100. data/app/javascript/lib/lexical/table_transformer.js +182 -0
  101. data/app/javascript/lib/lexical/trailing_paragraph.js +29 -0
  102. data/app/javascript/lib/turbo_confirm.js +46 -0
  103. data/app/javascript/lib/typo_correction.js +146 -0
  104. data/app/javascript/lib/utils/__tests__/confirm_dialog.test.js +88 -0
  105. data/app/javascript/lib/utils/__tests__/dialog.test.js +92 -0
  106. data/app/javascript/lib/utils/__tests__/markdown.test.js +153 -0
  107. data/app/javascript/lib/utils/__tests__/sanitize_description.test.js +68 -0
  108. data/app/javascript/lib/utils/__tests__/table_download.test.js +93 -0
  109. data/app/javascript/lib/utils/confirm_dialog.js +10 -0
  110. data/app/javascript/lib/utils/dialog.js +300 -0
  111. data/app/javascript/lib/utils/markdown.js +154 -67
  112. data/app/javascript/lib/utils/sanitize_description.js +31 -0
  113. data/app/javascript/lib/utils/table_download.js +15 -0
  114. data/app/javascript/modules/__tests__/creative_inline_payload.test.js +231 -0
  115. data/app/javascript/modules/__tests__/creative_save_queue.test.js +228 -0
  116. data/app/javascript/modules/__tests__/creative_tree_dom.test.js +425 -0
  117. data/app/javascript/modules/__tests__/typo_corrector.test.js +365 -0
  118. data/app/javascript/modules/creative_inline_payload.js +86 -0
  119. data/app/javascript/modules/creative_row_editor.js +231 -471
  120. data/app/javascript/modules/creative_row_editor_helpers.js +76 -0
  121. data/app/javascript/modules/creative_save_queue.js +95 -0
  122. data/app/javascript/modules/creative_tree_dom.js +260 -0
  123. data/app/javascript/modules/export_to_markdown.js +2 -1
  124. data/app/javascript/modules/lexical_inline_editor.jsx +2 -1
  125. data/app/javascript/modules/slide_view.js +11 -2
  126. data/app/javascript/modules/typo_corrector.js +534 -0
  127. data/app/jobs/collavre/ai_agent_job.rb +7 -4
  128. data/app/jobs/collavre/compress_job.rb +6 -2
  129. data/app/jobs/collavre/permission_cache_job.rb +20 -2
  130. data/app/jobs/collavre/trigger_loop_check_job.rb +20 -3
  131. data/app/jobs/collavre/update_mcp_tools_job.rb +17 -0
  132. data/app/models/collavre/channel.rb +16 -0
  133. data/app/models/collavre/comment/approvable.rb +16 -0
  134. data/app/models/collavre/comment/broadcastable.rb +46 -7
  135. data/app/models/collavre/comment/notifiable.rb +15 -5
  136. data/app/models/collavre/comment.rb +87 -32
  137. data/app/models/collavre/comment_version.rb +1 -1
  138. data/app/models/collavre/creative/describable.rb +104 -19
  139. data/app/models/collavre/creative/linkable.rb +9 -0
  140. data/app/models/collavre/creative/permissible.rb +97 -49
  141. data/app/models/collavre/creative.rb +73 -5
  142. data/app/models/collavre/creative_share.rb +78 -26
  143. data/app/models/collavre/inbox_item.rb +2 -2
  144. data/app/models/collavre/task.rb +27 -0
  145. data/app/models/collavre/user.rb +90 -10
  146. data/app/models/concerns/collavre/indexed_json_columns.rb +58 -0
  147. data/app/services/collavre/agent_type_classifier.rb +23 -0
  148. data/app/services/collavre/ai_agent/a2a_dispatcher.rb +4 -0
  149. data/app/services/collavre/ai_agent/message_builder.rb +1 -1
  150. data/app/services/collavre/ai_agent/session_provisioner.rb +126 -0
  151. data/app/services/collavre/ai_agent/task_claim_service.rb +96 -0
  152. data/app/services/collavre/ai_client.rb +81 -12
  153. data/app/services/collavre/auto_theme_generator.rb +15 -11
  154. data/app/services/collavre/creatives/children_index.rb +86 -0
  155. data/app/services/collavre/creatives/comment_badge_index.rb +73 -0
  156. data/app/services/collavre/creatives/creative_tree_serializer.rb +122 -0
  157. data/app/services/collavre/creatives/effective_creative_resolution.rb +37 -0
  158. data/app/services/collavre/creatives/index_query.rb +85 -16
  159. data/app/services/collavre/creatives/permission_checker.rb +1 -1
  160. data/app/services/collavre/creatives/permission_filter.rb +144 -17
  161. data/app/services/collavre/creatives/tree_builder.rb +107 -78
  162. data/app/services/collavre/gemini_parent_recommender.rb +1 -1
  163. data/app/services/collavre/http_client.rb +119 -0
  164. data/app/services/collavre/inbox_reply_service.rb +5 -0
  165. data/app/services/collavre/link_preview_fetcher.rb +129 -38
  166. data/app/services/collavre/markdown_converter.rb +13 -3
  167. data/app/services/collavre/markdown_importer.rb +30 -6
  168. data/app/services/collavre/mobile/event_summarizer.rb +40 -0
  169. data/app/services/collavre/orchestration/agent_context_builder.rb +1 -15
  170. data/app/services/collavre/orchestration/agent_orchestrator.rb +34 -8
  171. data/app/services/collavre/orchestration/arbiter.rb +16 -0
  172. data/app/services/collavre/orchestration/matcher.rb +79 -4
  173. data/app/services/collavre/orchestration/policy_resolver.rb +4 -3
  174. data/app/services/collavre/orchestration/stuck_detector.rb +141 -34
  175. data/app/services/collavre/system_events/context_builder.rb +1 -14
  176. data/app/services/collavre/tools/creative_batch_service.rb +3 -2
  177. data/app/services/collavre/tools/creative_create_service.rb +8 -8
  178. data/app/services/collavre/tools/creative_update_service.rb +23 -8
  179. data/app/services/collavre/tools/preview_attach_service.rb +5 -7
  180. data/app/services/collavre/typo_corrector.rb +188 -0
  181. data/app/views/collavre/comments/_comment.html.erb +9 -4
  182. data/app/views/collavre/comments/_comments_popup.html.erb +25 -4
  183. data/app/views/collavre/creatives/_inline_edit_form.html.erb +10 -0
  184. data/app/views/collavre/creatives/_topic_move_members_modal.html.erb +42 -0
  185. data/app/views/collavre/creatives/index.html.erb +14 -1
  186. data/app/views/collavre/creatives/slide_view.html.erb +1 -1
  187. data/app/views/collavre/landing/show.html.erb +52 -10
  188. data/app/views/collavre/users/show.html.erb +3 -0
  189. data/app/views/collavre/users/typo_correction.html.erb +50 -0
  190. data/app/views/layouts/collavre/slide.html.erb +1 -0
  191. data/config/locales/comments.en.yml +16 -0
  192. data/config/locales/comments.ko.yml +16 -0
  193. data/config/locales/creatives.en.yml +6 -0
  194. data/config/locales/creatives.ko.yml +5 -0
  195. data/config/locales/integrations.en.yml +1 -1
  196. data/config/locales/integrations.ko.yml +1 -1
  197. data/config/locales/landing.en.yml +22 -10
  198. data/config/locales/landing.ko.yml +22 -10
  199. data/config/locales/mobile.en.yml +16 -0
  200. data/config/locales/mobile.ko.yml +16 -0
  201. data/config/locales/orchestration.en.yml +1 -0
  202. data/config/locales/orchestration.ko.yml +1 -0
  203. data/config/locales/users.en.yml +15 -0
  204. data/config/locales/users.ko.yml +15 -0
  205. data/config/routes.rb +13 -0
  206. data/db/migrate/20260120045354_encrypt_oauth_tokens.rb +13 -4
  207. data/db/migrate/20260612000000_add_topic_concurrency_defer_to_comments.rb +38 -0
  208. data/db/migrate/20260617090000_add_typo_correction_settings_to_users.rb +18 -0
  209. data/db/migrate/20260702000001_enforce_system_settings_key_not_null.rb +9 -0
  210. data/db/migrate/20260702000002_add_creative_index_and_fk_to_tags.rb +11 -0
  211. data/db/migrate/20260702000003_add_creative_type_index_to_labels.rb +10 -0
  212. data/db/migrate/20260702000004_add_creative_created_at_index_to_comments.rb +10 -0
  213. data/db/migrate/20260702000005_promote_channel_config_index_columns.rb +76 -0
  214. data/db/migrate/20260713000001_add_comments_count_to_creatives.rb +20 -0
  215. data/db/migrate/20260713000002_add_comment_versions_count_to_comments.rb +20 -0
  216. data/db/migrate/20260715000000_change_creatives_description_to_plain_text.rb +16 -0
  217. data/db/seeds.rb +51 -0
  218. data/lib/collavre/version.rb +1 -1
  219. data/lib/generators/collavre/install/install_generator.rb +1 -0
  220. metadata +96 -2
  221. 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
- Collavre::EmailVerificationMailer.verify(@user).deliver_later
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
@@ -14,6 +14,7 @@ module Collavre
14
14
  collavre/org_chart
15
15
  collavre/popup
16
16
  collavre/comments_popup
17
+ collavre/tables
17
18
  collavre/code_highlight
18
19
  collavre/comment_versions
19
20
  collavre/mention_menu
@@ -18,14 +18,25 @@ module Collavre
18
18
  end
19
19
 
20
20
  def render_creative_tags(creative)
21
- labels = creative.tags&.includes(:label)&.map(&:label)&.compact
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
- def render_creative_progress(creative, select_mode: false, has_children: nil)
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 creative.has_permission?(Current.user, :feedback)
52
+ elsif can_feedback
40
53
  origin = creative.effective_origin
41
- comments_count = origin.comments.size
42
- pointer = CommentReadPointer.find_by(user: Current.user, creative: origin)
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
@@ -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
- setSelected(false)
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, setSelected])
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
- return registerCodeHighlighting(editor)
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
- const selection = $getSelection()
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
- // No Trix wrapper
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