collavre 0.1.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 (410) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +221 -0
  3. data/Rakefile +8 -0
  4. data/app/assets/stylesheets/collavre/actiontext.css +577 -0
  5. data/app/assets/stylesheets/collavre/activity_logs.css +99 -0
  6. data/app/assets/stylesheets/collavre/comments_popup.css +692 -0
  7. data/app/assets/stylesheets/collavre/creatives.css +559 -0
  8. data/app/assets/stylesheets/collavre/dark_mode.css +118 -0
  9. data/app/assets/stylesheets/collavre/mention_menu.css +43 -0
  10. data/app/assets/stylesheets/collavre/popup.css +160 -0
  11. data/app/assets/stylesheets/collavre/print.css +37 -0
  12. data/app/assets/stylesheets/collavre/slide_view.css +79 -0
  13. data/app/assets/stylesheets/collavre/user_menu.css +34 -0
  14. data/app/channels/collavre/comments_presence_channel.rb +54 -0
  15. data/app/channels/collavre/slide_view_channel.rb +11 -0
  16. data/app/channels/collavre/topics_channel.rb +12 -0
  17. data/app/components/collavre/avatar_component.html.erb +15 -0
  18. data/app/components/collavre/avatar_component.rb +59 -0
  19. data/app/components/collavre/inbox/badge_component.html.erb +6 -0
  20. data/app/components/collavre/inbox/badge_component.rb +18 -0
  21. data/app/components/collavre/plans_timeline_component.html.erb +14 -0
  22. data/app/components/collavre/plans_timeline_component.rb +56 -0
  23. data/app/components/collavre/popup_menu_component.html.erb +6 -0
  24. data/app/components/collavre/popup_menu_component.rb +30 -0
  25. data/app/components/collavre/progress_filter_component.html.erb +5 -0
  26. data/app/components/collavre/progress_filter_component.rb +10 -0
  27. data/app/components/collavre/user_mention_menu_component.html.erb +3 -0
  28. data/app/components/collavre/user_mention_menu_component.rb +8 -0
  29. data/app/controllers/collavre/application_controller.rb +15 -0
  30. data/app/controllers/collavre/attachments_controller.rb +44 -0
  31. data/app/controllers/collavre/calendar_events_controller.rb +15 -0
  32. data/app/controllers/collavre/comment_read_pointers_controller.rb +80 -0
  33. data/app/controllers/collavre/comments/activity_logs_controller.rb +26 -0
  34. data/app/controllers/collavre/comments/reactions_controller.rb +82 -0
  35. data/app/controllers/collavre/comments_controller.rb +464 -0
  36. data/app/controllers/collavre/contacts_controller.rb +10 -0
  37. data/app/controllers/collavre/creative_expanded_states_controller.rb +27 -0
  38. data/app/controllers/collavre/creative_imports_controller.rb +24 -0
  39. data/app/controllers/collavre/creative_plans_controller.rb +69 -0
  40. data/app/controllers/collavre/creative_shares_controller.rb +79 -0
  41. data/app/controllers/collavre/creatives_controller.rb +535 -0
  42. data/app/controllers/collavre/devices_controller.rb +19 -0
  43. data/app/controllers/collavre/email_verifications_controller.rb +16 -0
  44. data/app/controllers/collavre/emails_controller.rb +11 -0
  45. data/app/controllers/collavre/github_auth_controller.rb +25 -0
  46. data/app/controllers/collavre/google_auth_controller.rb +43 -0
  47. data/app/controllers/collavre/inbox_items_controller.rb +64 -0
  48. data/app/controllers/collavre/invites_controller.rb +27 -0
  49. data/app/controllers/collavre/notion_auth_controller.rb +25 -0
  50. data/app/controllers/collavre/passwords_controller.rb +37 -0
  51. data/app/controllers/collavre/plans_controller.rb +110 -0
  52. data/app/controllers/collavre/sessions_controller.rb +57 -0
  53. data/app/controllers/collavre/topics_controller.rb +58 -0
  54. data/app/controllers/collavre/user_themes_controller.rb +58 -0
  55. data/app/controllers/collavre/users_controller.rb +390 -0
  56. data/app/helpers/collavre/application_helper.rb +4 -0
  57. data/app/helpers/collavre/comments_helper.rb +9 -0
  58. data/app/helpers/collavre/creatives_helper.rb +343 -0
  59. data/app/helpers/collavre/navigation_helper.rb +163 -0
  60. data/app/helpers/collavre/user_themes_helper.rb +4 -0
  61. data/app/javascript/collavre.js +26 -0
  62. data/app/javascript/components/InlineLexicalEditor.jsx +889 -0
  63. data/app/javascript/components/LinkPopup.jsx +112 -0
  64. data/app/javascript/components/creative_tree_row.js +503 -0
  65. data/app/javascript/components/plugins/attachment_cleanup_plugin.jsx +95 -0
  66. data/app/javascript/components/plugins/image_upload_plugin.jsx +162 -0
  67. data/app/javascript/controllers/click_target_controller.js +13 -0
  68. data/app/javascript/controllers/comment_controller.js +162 -0
  69. data/app/javascript/controllers/comments/__tests__/popup_controller.test.js +68 -0
  70. data/app/javascript/controllers/comments/form_controller.js +530 -0
  71. data/app/javascript/controllers/comments/list_controller.js +715 -0
  72. data/app/javascript/controllers/comments/mention_menu_controller.js +41 -0
  73. data/app/javascript/controllers/comments/popup_controller.js +385 -0
  74. data/app/javascript/controllers/comments/presence_controller.js +311 -0
  75. data/app/javascript/controllers/comments/topics_controller.js +338 -0
  76. data/app/javascript/controllers/common_popup_controller.js +55 -0
  77. data/app/javascript/controllers/creatives/drag_drop_controller.js +45 -0
  78. data/app/javascript/controllers/creatives/expansion_controller.js +222 -0
  79. data/app/javascript/controllers/creatives/import_controller.js +116 -0
  80. data/app/javascript/controllers/creatives/row_editor_controller.js +8 -0
  81. data/app/javascript/controllers/creatives/select_mode_controller.js +231 -0
  82. data/app/javascript/controllers/creatives/set_plan_modal_controller.js +107 -0
  83. data/app/javascript/controllers/creatives/tree_controller.js +218 -0
  84. data/app/javascript/controllers/index.js +79 -0
  85. data/app/javascript/controllers/link_creative_controller.js +91 -0
  86. data/app/javascript/controllers/popup_menu_controller.js +82 -0
  87. data/app/javascript/controllers/progress_filter_controller.js +35 -0
  88. data/app/javascript/controllers/reaction_picker_controller.js +107 -0
  89. data/app/javascript/controllers/share_invite_controller.js +15 -0
  90. data/app/javascript/controllers/share_user_search_controller.js +121 -0
  91. data/app/javascript/controllers/tabs_controller.js +43 -0
  92. data/app/javascript/creatives/drag_drop/dom.js +170 -0
  93. data/app/javascript/creatives/drag_drop/event_handlers.js +846 -0
  94. data/app/javascript/creatives/drag_drop/indicator.js +35 -0
  95. data/app/javascript/creatives/drag_drop/operations.js +116 -0
  96. data/app/javascript/creatives/drag_drop/state.js +31 -0
  97. data/app/javascript/creatives/tree_renderer.js +248 -0
  98. data/app/javascript/lib/api/__tests__/queue_manager.test.js +153 -0
  99. data/app/javascript/lib/api/creatives.js +79 -0
  100. data/app/javascript/lib/api/csrf_fetch.js +22 -0
  101. data/app/javascript/lib/api/drag_drop.js +31 -0
  102. data/app/javascript/lib/api/queue_manager.js +423 -0
  103. data/app/javascript/lib/apply_lexical_styles.js +15 -0
  104. data/app/javascript/lib/common_popup.js +195 -0
  105. data/app/javascript/lib/lexical/__tests__/action_text_attachment_node.test.jsx +91 -0
  106. data/app/javascript/lib/lexical/__tests__/attachment_payload.test.js +194 -0
  107. data/app/javascript/lib/lexical/action_text_attachment_node.js +459 -0
  108. data/app/javascript/lib/lexical/attachment_node.jsx +170 -0
  109. data/app/javascript/lib/lexical/attachment_payload.js +293 -0
  110. data/app/javascript/lib/lexical/dom_attachment_utils.js +66 -0
  111. data/app/javascript/lib/lexical/image_node.jsx +159 -0
  112. data/app/javascript/lib/lexical/style_attributes.js +40 -0
  113. data/app/javascript/lib/responsive_images.js +54 -0
  114. data/app/javascript/lib/turbo_stream_actions.js +33 -0
  115. data/app/javascript/lib/utils/markdown.js +23 -0
  116. data/app/javascript/modules/creative_guide.js +53 -0
  117. data/app/javascript/modules/creative_row_editor.js +1841 -0
  118. data/app/javascript/modules/creative_row_swipe.js +43 -0
  119. data/app/javascript/modules/creatives.js +15 -0
  120. data/app/javascript/modules/export_to_markdown.js +34 -0
  121. data/app/javascript/modules/inbox_panel.js +226 -0
  122. data/app/javascript/modules/lexical_inline_editor.jsx +133 -0
  123. data/app/javascript/modules/mention_menu.js +77 -0
  124. data/app/javascript/modules/plans_menu.js +39 -0
  125. data/app/javascript/modules/plans_timeline.js +397 -0
  126. data/app/javascript/modules/share_modal.js +73 -0
  127. data/app/javascript/modules/share_user_popup.js +77 -0
  128. data/app/javascript/modules/slide_view.js +163 -0
  129. data/app/javascript/services/cable.js +32 -0
  130. data/app/javascript/slide_view.js +2 -0
  131. data/app/javascript/utils/caret_position.js +42 -0
  132. data/app/javascript/utils/clipboard.js +40 -0
  133. data/app/jobs/collavre/ai_agent_job.rb +27 -0
  134. data/app/jobs/collavre/inbox_summary_job.rb +24 -0
  135. data/app/jobs/collavre/notion_export_job.rb +30 -0
  136. data/app/jobs/collavre/notion_sync_job.rb +48 -0
  137. data/app/jobs/collavre/permission_cache_cleanup_job.rb +36 -0
  138. data/app/jobs/collavre/permission_cache_job.rb +71 -0
  139. data/app/jobs/collavre/push_notification_job.rb +86 -0
  140. data/app/mailers/collavre/application_mailer.rb +17 -0
  141. data/app/mailers/collavre/creative_mailer.rb +9 -0
  142. data/app/mailers/collavre/email_verification_mailer.rb +20 -0
  143. data/app/mailers/collavre/inbox_mailer.rb +19 -0
  144. data/app/mailers/collavre/invitation_mailer.rb +16 -0
  145. data/app/mailers/collavre/passwords_mailer.rb +10 -0
  146. data/app/models/collavre/activity_log.rb +13 -0
  147. data/app/models/collavre/application_record.rb +5 -0
  148. data/app/models/collavre/calendar_event.rb +20 -0
  149. data/app/models/collavre/comment.rb +307 -0
  150. data/app/models/collavre/comment_presence_store.rb +30 -0
  151. data/app/models/collavre/comment_reaction.rb +11 -0
  152. data/app/models/collavre/comment_read_pointer.rb +26 -0
  153. data/app/models/collavre/contact.rb +23 -0
  154. data/app/models/collavre/creative.rb +413 -0
  155. data/app/models/collavre/creative_expanded_state.rb +11 -0
  156. data/app/models/collavre/creative_share.rb +122 -0
  157. data/app/models/collavre/creative_shares_cache.rb +18 -0
  158. data/app/models/collavre/current.rb +14 -0
  159. data/app/models/collavre/device.rb +13 -0
  160. data/app/models/collavre/email.rb +14 -0
  161. data/app/models/collavre/github_account.rb +10 -0
  162. data/app/models/collavre/github_repository_link.rb +19 -0
  163. data/app/models/collavre/inbox_item.rb +95 -0
  164. data/app/models/collavre/invitation.rb +22 -0
  165. data/app/models/collavre/label.rb +47 -0
  166. data/app/models/collavre/mcp_tool.rb +30 -0
  167. data/app/models/collavre/notion_account.rb +17 -0
  168. data/app/models/collavre/notion_block_link.rb +10 -0
  169. data/app/models/collavre/notion_page_link.rb +19 -0
  170. data/app/models/collavre/plan.rb +20 -0
  171. data/app/models/collavre/session.rb +24 -0
  172. data/app/models/collavre/system_setting.rb +144 -0
  173. data/app/models/collavre/tag.rb +10 -0
  174. data/app/models/collavre/task.rb +10 -0
  175. data/app/models/collavre/task_action.rb +10 -0
  176. data/app/models/collavre/topic.rb +12 -0
  177. data/app/models/collavre/user.rb +174 -0
  178. data/app/models/collavre/user_theme.rb +10 -0
  179. data/app/models/collavre/variation.rb +5 -0
  180. data/app/models/collavre/webauthn_credential.rb +11 -0
  181. data/app/services/collavre/ai_agent_service.rb +193 -0
  182. data/app/services/collavre/ai_client.rb +183 -0
  183. data/app/services/collavre/ai_system_prompt_renderer.rb +38 -0
  184. data/app/services/collavre/auto_theme_generator.rb +198 -0
  185. data/app/services/collavre/comment_link_formatter.rb +60 -0
  186. data/app/services/collavre/comments/action_executor.rb +262 -0
  187. data/app/services/collavre/comments/action_validator.rb +58 -0
  188. data/app/services/collavre/comments/calendar_command.rb +97 -0
  189. data/app/services/collavre/comments/command_processor.rb +37 -0
  190. data/app/services/collavre/comments/mcp_command.rb +109 -0
  191. data/app/services/collavre/comments/mcp_command_builder.rb +32 -0
  192. data/app/services/collavre/creatives/filter_pipeline.rb +196 -0
  193. data/app/services/collavre/creatives/filters/assignee_filter.rb +30 -0
  194. data/app/services/collavre/creatives/filters/base_filter.rb +24 -0
  195. data/app/services/collavre/creatives/filters/comment_filter.rb +21 -0
  196. data/app/services/collavre/creatives/filters/date_filter.rb +58 -0
  197. data/app/services/collavre/creatives/filters/progress_filter.rb +25 -0
  198. data/app/services/collavre/creatives/filters/search_filter.rb +28 -0
  199. data/app/services/collavre/creatives/filters/tag_filter.rb +16 -0
  200. data/app/services/collavre/creatives/importer.rb +47 -0
  201. data/app/services/collavre/creatives/index_query.rb +191 -0
  202. data/app/services/collavre/creatives/path_exporter.rb +131 -0
  203. data/app/services/collavre/creatives/permission_cache_builder.rb +194 -0
  204. data/app/services/collavre/creatives/permission_checker.rb +42 -0
  205. data/app/services/collavre/creatives/plan_tagger.rb +53 -0
  206. data/app/services/collavre/creatives/progress_service.rb +89 -0
  207. data/app/services/collavre/creatives/reorderer.rb +187 -0
  208. data/app/services/collavre/creatives/tree_builder.rb +231 -0
  209. data/app/services/collavre/creatives/tree_formatter.rb +36 -0
  210. data/app/services/collavre/gemini_parent_recommender.rb +77 -0
  211. data/app/services/collavre/github/client.rb +112 -0
  212. data/app/services/collavre/github/pull_request_analyzer.rb +280 -0
  213. data/app/services/collavre/github/pull_request_processor.rb +181 -0
  214. data/app/services/collavre/github/webhook_provisioner.rb +130 -0
  215. data/app/services/collavre/google_calendar_service.rb +149 -0
  216. data/app/services/collavre/link_preview_fetcher.rb +230 -0
  217. data/app/services/collavre/markdown_importer.rb +202 -0
  218. data/app/services/collavre/mcp_service.rb +217 -0
  219. data/app/services/collavre/notion_client.rb +231 -0
  220. data/app/services/collavre/notion_creative_exporter.rb +296 -0
  221. data/app/services/collavre/notion_service.rb +249 -0
  222. data/app/services/collavre/ppt_importer.rb +76 -0
  223. data/app/services/collavre/ruby_llm_interaction_logger.rb +34 -0
  224. data/app/services/collavre/system_events/context_builder.rb +41 -0
  225. data/app/services/collavre/system_events/dispatcher.rb +19 -0
  226. data/app/services/collavre/system_events/router.rb +72 -0
  227. data/app/services/collavre/tools/creative_retrieval_service.rb +138 -0
  228. data/app/views/admin/shared/_tabs.html.erb +4 -0
  229. data/app/views/collavre/comments/_activity_log_details.html.erb +23 -0
  230. data/app/views/collavre/comments/_comment.html.erb +147 -0
  231. data/app/views/collavre/comments/_comments_popup.html.erb +46 -0
  232. data/app/views/collavre/comments/_list.html.erb +10 -0
  233. data/app/views/collavre/comments/_presence_avatars.html.erb +8 -0
  234. data/app/views/collavre/comments/_reaction_picker.html.erb +15 -0
  235. data/app/views/collavre/comments/_read_receipts.html.erb +19 -0
  236. data/app/views/collavre/creatives/_add_button.html.erb +20 -0
  237. data/app/views/collavre/creatives/_delete_button.html.erb +12 -0
  238. data/app/views/collavre/creatives/_github_integration_modal.html.erb +77 -0
  239. data/app/views/collavre/creatives/_import_upload_zone.html.erb +10 -0
  240. data/app/views/collavre/creatives/_inline_edit_form.html.erb +89 -0
  241. data/app/views/collavre/creatives/_mobile_actions_menu.html.erb +24 -0
  242. data/app/views/collavre/creatives/_notion_integration_modal.html.erb +90 -0
  243. data/app/views/collavre/creatives/_set_plan_modal.html.erb +25 -0
  244. data/app/views/collavre/creatives/_share_button.html.erb +55 -0
  245. data/app/views/collavre/creatives/edit.html.erb +4 -0
  246. data/app/views/collavre/creatives/index.html.erb +192 -0
  247. data/app/views/collavre/creatives/new.html.erb +4 -0
  248. data/app/views/collavre/creatives/show.html.erb +29 -0
  249. data/app/views/collavre/creatives/slide_view.html.erb +20 -0
  250. data/app/views/collavre/email_verification_mailer/verify.html.erb +4 -0
  251. data/app/views/collavre/email_verification_mailer/verify.text.erb +2 -0
  252. data/app/views/collavre/emails/index.html.erb +19 -0
  253. data/app/views/collavre/emails/show.html.erb +5 -0
  254. data/app/views/collavre/inbox_items/_item.html.erb +18 -0
  255. data/app/views/collavre/inbox_items/_items.html.erb +3 -0
  256. data/app/views/collavre/inbox_items/_list.html.erb +7 -0
  257. data/app/views/collavre/inbox_items/index.html.erb +1 -0
  258. data/app/views/collavre/inbox_mailer/daily_summary.html.erb +11 -0
  259. data/app/views/collavre/inbox_mailer/daily_summary.text.erb +8 -0
  260. data/app/views/collavre/invitation_mailer/invite.html.erb +5 -0
  261. data/app/views/collavre/invitation_mailer/invite.text.erb +3 -0
  262. data/app/views/collavre/invites/show.html.erb +8 -0
  263. data/app/views/collavre/passwords/edit.html.erb +12 -0
  264. data/app/views/collavre/passwords/new.html.erb +17 -0
  265. data/app/views/collavre/passwords_mailer/reset.html.erb +4 -0
  266. data/app/views/collavre/passwords_mailer/reset.text.erb +2 -0
  267. data/app/views/collavre/sessions/new.html.erb +27 -0
  268. data/app/views/collavre/sessions/providers/_google.html.erb +7 -0
  269. data/app/views/collavre/sessions/providers/_passkey.html.erb +3 -0
  270. data/app/views/collavre/shared/_link_creative_modal.html.erb +6 -0
  271. data/app/views/collavre/shared/_navigation.html.erb +37 -0
  272. data/app/views/collavre/shared/navigation/_help_button.html.erb +1 -0
  273. data/app/views/collavre/shared/navigation/_inbox_button.html.erb +5 -0
  274. data/app/views/collavre/shared/navigation/_mobile_inbox_button.html.erb +3 -0
  275. data/app/views/collavre/shared/navigation/_mobile_plans_button.html.erb +1 -0
  276. data/app/views/collavre/shared/navigation/_panels.html.erb +18 -0
  277. data/app/views/collavre/shared/navigation/_plans_button.html.erb +3 -0
  278. data/app/views/collavre/shared/navigation/_search_form.html.erb +6 -0
  279. data/app/views/collavre/user_themes/index.html.erb +75 -0
  280. data/app/views/collavre/users/_app_header.html.erb +5 -0
  281. data/app/views/collavre/users/_contact_management.html.erb +77 -0
  282. data/app/views/collavre/users/_id_pwd_fields.html.erb +27 -0
  283. data/app/views/collavre/users/edit_ai.html.erb +79 -0
  284. data/app/views/collavre/users/edit_password.html.erb +27 -0
  285. data/app/views/collavre/users/index.html.erb +80 -0
  286. data/app/views/collavre/users/new.html.erb +33 -0
  287. data/app/views/collavre/users/new_ai.html.erb +87 -0
  288. data/app/views/collavre/users/passkeys.html.erb +43 -0
  289. data/app/views/collavre/users/show.html.erb +143 -0
  290. data/app/views/inbox/badge_component/_count.html.erb +2 -0
  291. data/app/views/layouts/collavre/slide.html.erb +29 -0
  292. data/config/locales/comments.en.yml +114 -0
  293. data/config/locales/comments.ko.yml +110 -0
  294. data/config/locales/contacts.en.yml +16 -0
  295. data/config/locales/contacts.ko.yml +16 -0
  296. data/config/locales/creatives.en.yml +183 -0
  297. data/config/locales/creatives.ko.yml +164 -0
  298. data/config/locales/invites.en.yml +19 -0
  299. data/config/locales/invites.ko.yml +17 -0
  300. data/config/locales/notifications.en.yml +8 -0
  301. data/config/locales/notifications.ko.yml +8 -0
  302. data/config/locales/plans.en.yml +12 -0
  303. data/config/locales/plans.ko.yml +19 -0
  304. data/config/locales/themes.en.yml +29 -0
  305. data/config/locales/themes.ko.yml +27 -0
  306. data/config/locales/users.en.yml +151 -0
  307. data/config/locales/users.ko.yml +146 -0
  308. data/config/routes.rb +92 -0
  309. data/db/migrate/20241201000000_create_notion_integrations.rb +29 -0
  310. data/db/migrate/20250128110017_create_creatives.rb +11 -0
  311. data/db/migrate/20250128120122_create_users.rb +11 -0
  312. data/db/migrate/20250128120123_create_sessions.rb +11 -0
  313. data/db/migrate/20250128123633_create_subscribers.rb +10 -0
  314. data/db/migrate/20250312000000_create_notion_block_links.rb +16 -0
  315. data/db/migrate/20250312010000_allow_multiple_notion_blocks_per_creative.rb +5 -0
  316. data/db/migrate/20250522115048_remove_name_from_creatives.rb +5 -0
  317. data/db/migrate/20250522190651_add_parent_id_to_creatives.rb +5 -0
  318. data/db/migrate/20250523133100_rename_inventory_count_to_progress.rb +13 -0
  319. data/db/migrate/20250523133101_add_sequence_to_creatives.rb +5 -0
  320. data/db/migrate/20250525205100_add_user_id_to_creatives.rb +10 -0
  321. data/db/migrate/20250527014217_create_creative_shares.rb +12 -0
  322. data/db/migrate/20250528142349_add_origin_id_to_creatives.rb +5 -0
  323. data/db/migrate/20250530060200_create_tags.rb +9 -0
  324. data/db/migrate/20250531105150_add_value_to_tags.rb +5 -0
  325. data/db/migrate/20250531140142_create_labels.rb +12 -0
  326. data/db/migrate/20250531140145_change_tag_to_label_reference.rb +6 -0
  327. data/db/migrate/20250601000000_create_comments.rb +10 -0
  328. data/db/migrate/20250601061830_drop_plans_and_variations.rb +6 -0
  329. data/db/migrate/20250604122600_add_owner_to_labels.rb +5 -0
  330. data/db/migrate/20250606000000_rename_email_address_to_email_in_users.rb +7 -0
  331. data/db/migrate/20250606150329_create_creative_hierarchies.rb +16 -0
  332. data/db/migrate/20250610142000_create_creative_expanded_states.rb +11 -0
  333. data/db/migrate/20250611030138_create_invitations.rb +15 -0
  334. data/db/migrate/20250611105524_create_inbox_items.rb +14 -0
  335. data/db/migrate/20250612150000_update_permissions.rb +43 -0
  336. data/db/migrate/20250612232913_add_email_verified_at_to_users.rb +5 -0
  337. data/db/migrate/20250616065905_add_avatar_to_users.rb +5 -0
  338. data/db/migrate/20250617092111_add_display_level_to_users.rb +5 -0
  339. data/db/migrate/20250620004558_create_emails.rb +13 -0
  340. data/db/migrate/20250621000000_create_comment_read_pointers.rb +11 -0
  341. data/db/migrate/20250622000000_add_completion_mark_to_users.rb +5 -0
  342. data/db/migrate/20250623000000_add_theme_to_users.rb +5 -0
  343. data/db/migrate/20250624000000_add_name_to_users.rb +18 -0
  344. data/db/migrate/20250714190000_create_devices.rb +17 -0
  345. data/db/migrate/20250715120000_add_notifications_enabled_to_users.rb +5 -0
  346. data/db/migrate/20250823000000_add_no_access_permission.rb +25 -0
  347. data/db/migrate/20250826000000_add_calendar_id_to_users.rb +5 -0
  348. data/db/migrate/20250827000000_add_google_oauth_tokens_to_users.rb +8 -0
  349. data/db/migrate/20250827061238_add_timezone_to_users.rb +5 -0
  350. data/db/migrate/20250828000000_create_calendar_events.rb +16 -0
  351. data/db/migrate/20250828060000_add_creative_to_calendar_events.rb +5 -0
  352. data/db/migrate/20250830141052_add_locale_to_users.rb +5 -0
  353. data/db/migrate/20250830141101_add_message_key_to_inbox_items.rb +6 -0
  354. data/db/migrate/20250902025423_remove_description_and_featured_image_from_creatives.rb +6 -0
  355. data/db/migrate/20250910000000_add_private_to_comments.rb +5 -0
  356. data/db/migrate/20250910105640_migrate_write_permissions_to_admin.rb +17 -0
  357. data/db/migrate/20250911084338_backfill_creative_in_comment_inbox_items.rb +27 -0
  358. data/db/migrate/20250923002959_deduplicate_device_fcm_tokens.rb +25 -0
  359. data/db/migrate/20250924000000_add_system_admin_to_users.rb +6 -0
  360. data/db/migrate/20250925000000_create_github_integrations.rb +26 -0
  361. data/db/migrate/20250927000000_add_webhook_secret_to_github_repository_links.rb +29 -0
  362. data/db/migrate/20250928000000_add_action_and_approver_to_comments.rb +6 -0
  363. data/db/migrate/20250928010000_add_action_execution_tracking_to_comments.rb +6 -0
  364. data/db/migrate/20250928105957_add_github_gemini_prompt_to_creatives.rb +5 -0
  365. data/db/migrate/20250929000000_add_comment_and_creative_refs_to_inbox_items.rb +6 -0
  366. data/db/migrate/20251001000001_create_contacts.rb +71 -0
  367. data/db/migrate/20251002000000_add_shared_by_to_creative_shares.rb +14 -0
  368. data/db/migrate/20251124120902_add_ai_fields_to_users.rb +7 -0
  369. data/db/migrate/20251124122218_add_created_by_id_to_users.rb +5 -0
  370. data/db/migrate/20251124124521_add_llm_api_key_to_users.rb +5 -0
  371. data/db/migrate/20251124130000_add_searchable_to_users.rb +6 -0
  372. data/db/migrate/20251125072705_migrate_linked_creative_children_to_origin.rb +28 -0
  373. data/db/migrate/20251126040752_add_description_to_creatives.rb +75 -0
  374. data/db/migrate/20251127000000_move_comments_from_linked_creatives_to_origins.rb +18 -0
  375. data/db/migrate/20251201073823_add_tools_to_users.rb +5 -0
  376. data/db/migrate/20251202062715_create_mcp_tools.rb +16 -0
  377. data/db/migrate/20251204125754_create_tasks_and_task_actions.rb +23 -0
  378. data/db/migrate/20251204125756_add_routing_expression_to_users.rb +5 -0
  379. data/db/migrate/20251204161133_set_default_routing_expression_for_ai_agents.rb +13 -0
  380. data/db/migrate/20251211033025_nullify_self_referencing_origins.rb +9 -0
  381. data/db/migrate/20251211080040_create_topics_and_add_topic_to_comments.rb +15 -0
  382. data/db/migrate/20251215143500_create_activity_logs.rb +16 -0
  383. data/db/migrate/20251222125727_create_webauthn_credentials.rb +14 -0
  384. data/db/migrate/20251222125839_add_webauthn_id_to_users.rb +5 -0
  385. data/db/migrate/20251223022315_create_comment_reactions.rb +13 -0
  386. data/db/migrate/20251223072625_create_user_themes.rb +11 -0
  387. data/db/migrate/20251230074456_add_creative_id_to_labels.rb +5 -0
  388. data/db/migrate/20251230113607_refactor_labels.rb +38 -0
  389. data/db/migrate/20251231010012_backfill_tags_for_labels.rb +15 -0
  390. data/db/migrate/20251231013234_drop_subscribers.rb +9 -0
  391. data/db/migrate/20260106090544_allow_null_user_id_in_creative_shares.rb +6 -0
  392. data/db/migrate/20260106160643_create_system_settings.rb +11 -0
  393. data/db/migrate/20260116000000_create_creative_shares_cache.rb +15 -0
  394. data/db/migrate/20260116000001_populate_creative_shares_cache.rb +23 -0
  395. data/db/migrate/20260119022933_make_source_share_id_nullable_in_creative_shares_caches.rb +7 -0
  396. data/db/migrate/20260119023446_populate_owner_cache_entries.rb +25 -0
  397. data/db/migrate/20260119100000_add_account_lockout_to_users.rb +6 -0
  398. data/db/migrate/20260119110000_add_last_active_at_to_sessions.rb +6 -0
  399. data/db/migrate/20260120045354_encrypt_oauth_tokens.rb +60 -0
  400. data/db/migrate/20260120162259_remove_fk_from_creative_shares_caches.rb +7 -0
  401. data/db/migrate/20260120163856_remove_timestamps_from_creative_shares_caches.rb +6 -0
  402. data/lib/collavre/configuration.rb +14 -0
  403. data/lib/collavre/engine.rb +77 -0
  404. data/lib/collavre/user_extensions.rb +29 -0
  405. data/lib/collavre/version.rb +3 -0
  406. data/lib/collavre.rb +26 -0
  407. data/lib/generators/collavre/install/install_generator.rb +105 -0
  408. data/lib/generators/collavre/install/templates/build.cjs.tt +100 -0
  409. data/lib/tasks/collavre_assets.rake +15 -0
  410. metadata +591 -0
@@ -0,0 +1,889 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react"
2
+ import { LexicalComposer } from "@lexical/react/LexicalComposer"
3
+ import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"
4
+ import { ContentEditable } from "@lexical/react/LexicalContentEditable"
5
+ import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin"
6
+ import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin"
7
+ import { ListPlugin } from "@lexical/react/LexicalListPlugin"
8
+ import { LinkPlugin } from "@lexical/react/LexicalLinkPlugin"
9
+ import {
10
+ AutoLinkPlugin,
11
+ createLinkMatcherWithRegExp
12
+ } from "@lexical/react/LexicalAutoLinkPlugin"
13
+ import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"
14
+ import { HeadingNode, QuoteNode } from "@lexical/rich-text"
15
+ import {
16
+ CodeNode,
17
+ CodeHighlightNode,
18
+ $createCodeNode,
19
+ $isCodeNode,
20
+ registerCodeHighlighting
21
+ } from "@lexical/code"
22
+ import { ListItemNode, ListNode, INSERT_ORDERED_LIST_COMMAND, INSERT_UNORDERED_LIST_COMMAND } from "@lexical/list"
23
+ import { $createLinkNode, LinkNode, AutoLinkNode, TOGGLE_LINK_COMMAND } from "@lexical/link"
24
+ import {
25
+ $createParagraphNode,
26
+ $createTextNode,
27
+ $getRoot,
28
+ $getSelection,
29
+ $isElementNode,
30
+ $isRangeSelection,
31
+ $isTextNode,
32
+ CAN_REDO_COMMAND,
33
+ CAN_UNDO_COMMAND,
34
+ COMMAND_PRIORITY_CRITICAL,
35
+ COMMAND_PRIORITY_LOW,
36
+ FORMAT_TEXT_COMMAND,
37
+ REDO_COMMAND,
38
+ SELECTION_CHANGE_COMMAND,
39
+ UNDO_COMMAND
40
+ } from "lexical"
41
+ import { $patchStyleText } from "@lexical/selection"
42
+ import { $generateHtmlFromNodes, $generateNodesFromDOM } from "@lexical/html"
43
+ import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
44
+ import FileUploadPlugin, {
45
+ INSERT_IMAGE_COMMAND,
46
+ INSERT_FILE_COMMAND
47
+ } from "./plugins/image_upload_plugin"
48
+ import { ImageNode } from "../lib/lexical/image_node"
49
+ import { AttachmentNode } from "../lib/lexical/attachment_node"
50
+ import AttachmentCleanupPlugin from "./plugins/attachment_cleanup_plugin"
51
+ import { syncLexicalStyleAttributes } from "../lib/lexical/style_attributes"
52
+ import { updateResponsiveImages } from "../lib/responsive_images"
53
+
54
+ const URL_MATCHERS = [
55
+ createLinkMatcherWithRegExp(/https?:\/\/[^\s<]+/gi, (text) => text)
56
+ ]
57
+
58
+ const theme = {
59
+ paragraph: "lexical-paragraph",
60
+ quote: "lexical-quote",
61
+ heading: {
62
+ h1: "lexical-heading-h1",
63
+ h2: "lexical-heading-h2",
64
+ h3: "lexical-heading-h3"
65
+ },
66
+ list: {
67
+ ul: "lexical-list-ul",
68
+ ol: "lexical-list-ol",
69
+ listitem: "lexical-list-item"
70
+ },
71
+ code: "lexical-code-block",
72
+ codeHighlight: {
73
+ atrule: "lexical-token-atrule",
74
+ attr: "lexical-token-attr",
75
+ boolean: "lexical-token-boolean",
76
+ builtin: "lexical-token-builtin",
77
+ cdata: "lexical-token-cdata",
78
+ char: "lexical-token-char",
79
+ class: "lexical-token-class",
80
+ comment: "lexical-token-comment",
81
+ constant: "lexical-token-constant",
82
+ deleted: "lexical-token-deleted",
83
+ doctype: "lexical-token-doctype",
84
+ entity: "lexical-token-entity",
85
+ function: "lexical-token-function",
86
+ important: "lexical-token-important",
87
+ inserted: "lexical-token-inserted",
88
+ keyword: "lexical-token-keyword",
89
+ namespace: "lexical-token-namespace",
90
+ number: "lexical-token-number",
91
+ operator: "lexical-token-operator",
92
+ prolog: "lexical-token-prolog",
93
+ property: "lexical-token-property",
94
+ punctuation: "lexical-token-punctuation",
95
+ regex: "lexical-token-regex",
96
+ selector: "lexical-token-selector",
97
+ string: "lexical-token-string",
98
+ symbol: "lexical-token-symbol",
99
+ tag: "lexical-token-tag",
100
+ url: "lexical-token-url",
101
+ variable: "lexical-token-variable"
102
+ },
103
+ link: "lexical-link",
104
+ text: {
105
+ bold: "lexical-text-bold",
106
+ italic: "lexical-text-italic",
107
+ underline: "lexical-text-underline",
108
+ strikethrough: "lexical-text-strike",
109
+ code: "lexical-text-code"
110
+ }
111
+ }
112
+
113
+ function Placeholder({ text }) {
114
+ const fallback = "Describe the creative…"
115
+ return <div className="lexical-placeholder">{text || fallback}</div>
116
+ }
117
+
118
+ function InitialContentPlugin({ html }) {
119
+ const [editor] = useLexicalComposerContext()
120
+ const lastApplied = useRef(null)
121
+
122
+ const collectDomTextStyles = useCallback((container) => {
123
+ const styles = []
124
+ if (!container) return styles
125
+ const ownerDocument = container.ownerDocument || document
126
+ const walker = ownerDocument.createTreeWalker(container, NodeFilter.SHOW_TEXT)
127
+ let current = walker.nextNode()
128
+ while (current) {
129
+ const parent = current.parentElement
130
+ let styleText = parent?.getAttribute?.("style") || ""
131
+ const colorAttr = parent?.dataset?.lexicalColor
132
+ const bgAttr = parent?.dataset?.lexicalBackgroundColor
133
+
134
+ if ((!styleText || !styleText.trim()) && (colorAttr || bgAttr)) {
135
+ const declarations = []
136
+ if (colorAttr) declarations.push(`color: ${colorAttr}`)
137
+ if (bgAttr) declarations.push(`background-color: ${bgAttr}`)
138
+ styleText = declarations.join("; ")
139
+ } else {
140
+ const lower = styleText.toLowerCase()
141
+ const fragments = []
142
+ if (colorAttr && !lower.includes("color:")) {
143
+ fragments.push(`color: ${colorAttr}`)
144
+ }
145
+ if (bgAttr && !lower.includes("background-color:")) {
146
+ fragments.push(`background-color: ${bgAttr}`)
147
+ }
148
+ if (fragments.length > 0) {
149
+ styleText = `${styleText}${styleText.trim().endsWith(";") || !styleText.trim() ? "" : ";"} ${fragments.join("; ")}`.trim()
150
+ }
151
+ }
152
+
153
+ styles.push(styleText || "")
154
+ current = walker.nextNode()
155
+ }
156
+ return styles
157
+ }, [])
158
+
159
+ useEffect(() => {
160
+ if (lastApplied.current === html) return
161
+ lastApplied.current = html
162
+ editor.update(() => {
163
+ const root = $getRoot()
164
+ // Explicitly remove all children to ensure it's empty
165
+ root.getChildren().forEach((child) => child.remove())
166
+
167
+ const parser = new DOMParser()
168
+ const doc = parser.parseFromString(html || "", "text/html")
169
+ // No more .trix-content wrapper
170
+ const container = doc.body
171
+
172
+ syncLexicalStyleAttributes(container)
173
+ const collectedStyles = collectDomTextStyles(container)
174
+ const nodes = $generateNodesFromDOM(editor, container)
175
+
176
+ // Filter out duplicate image nodes if any
177
+ const uniqueNodes = []
178
+ const seenImages = new Set()
179
+
180
+ nodes.forEach(node => {
181
+ // Check if the node is an ImageNode and if it has a getSrc method
182
+ if (node.getType() === 'image' && typeof node.getSrc === 'function') {
183
+ const src = node.getSrc()
184
+ if (!seenImages.has(src)) {
185
+ seenImages.add(src)
186
+ uniqueNodes.push(node)
187
+ }
188
+ } else {
189
+ uniqueNodes.push(node)
190
+ }
191
+ })
192
+
193
+ const appendedNodes = []
194
+ uniqueNodes.forEach((node) => {
195
+ if ($isTextNode(node)) {
196
+ const paragraph = $createParagraphNode()
197
+ paragraph.append(node)
198
+ root.append(paragraph)
199
+ appendedNodes.push(paragraph)
200
+ return
201
+ }
202
+
203
+ if ($isElementNode(node) && node.getType?.() === "paragraph") {
204
+ root.append(node)
205
+ appendedNodes.push(node)
206
+ return
207
+ }
208
+
209
+ root.append(node)
210
+ appendedNodes.push(node)
211
+ })
212
+
213
+ if (root.getChildrenSize() === 0) {
214
+ const paragraph = $createParagraphNode()
215
+ root.append(paragraph)
216
+ appendedNodes.push(paragraph)
217
+ }
218
+
219
+ const textNodes = root.getAllTextNodes()
220
+ textNodes.forEach((textNode, index) => {
221
+ const style = collectedStyles[index]
222
+ textNode.setStyle(style || "")
223
+ })
224
+
225
+ let lastChild = root.getLastChild()
226
+ while (
227
+ lastChild &&
228
+ lastChild.getType?.() === "paragraph" &&
229
+ lastChild.getChildrenSize?.() === 0
230
+ ) {
231
+ lastChild.remove()
232
+ lastChild = root.getLastChild()
233
+ }
234
+
235
+ if (root.getChildrenSize() === 0) {
236
+ root.append($createParagraphNode())
237
+ }
238
+ })
239
+ }, [collectDomTextStyles, editor, html])
240
+
241
+ return null
242
+ }
243
+
244
+ function LinkAttributesPlugin() {
245
+ const [editor] = useLexicalComposerContext()
246
+
247
+ useEffect(() => {
248
+ return editor.registerUpdateListener(() => {
249
+ const rootElement = editor.getRootElement()
250
+ if (!rootElement) return
251
+ rootElement.querySelectorAll("a").forEach((anchor) => {
252
+ if (!anchor.getAttribute("target")) {
253
+ anchor.setAttribute("target", "_blank")
254
+ }
255
+ const rel = anchor.getAttribute("rel") || ""
256
+ if (!rel.includes("noopener")) {
257
+ anchor.setAttribute("rel", (rel + " noopener").trim())
258
+ }
259
+ })
260
+ })
261
+ }, [editor])
262
+
263
+ return null
264
+ }
265
+
266
+ function CodeHighlightingPlugin() {
267
+ const [editor] = useLexicalComposerContext()
268
+
269
+ useEffect(() => {
270
+ return registerCodeHighlighting(editor)
271
+ }, [editor])
272
+
273
+ return null
274
+ }
275
+
276
+ function ToolbarColorPicker({ icon, title, color, onChange, onClear }) {
277
+ const [open, setOpen] = useState(false)
278
+ const triggerRef = useRef(null)
279
+ const popoverRef = useRef(null)
280
+
281
+ useEffect(() => {
282
+ if (!open) return
283
+ const handleClick = (event) => {
284
+ if (
285
+ popoverRef.current &&
286
+ !popoverRef.current.contains(event.target) &&
287
+ triggerRef.current &&
288
+ !triggerRef.current.contains(event.target)
289
+ ) {
290
+ setOpen(false)
291
+ }
292
+ }
293
+ document.addEventListener("mousedown", handleClick)
294
+ return () => document.removeEventListener("mousedown", handleClick)
295
+ }, [open])
296
+
297
+ return (
298
+ <div className="lexical-toolbar-color" title={title}>
299
+ <button
300
+ type="button"
301
+ className="lexical-toolbar-btn lexical-toolbar-color__trigger"
302
+ onClick={() => setOpen((prev) => !prev)}
303
+ ref={triggerRef}>
304
+ <span className="lexical-toolbar-color__swatch" style={{ backgroundColor: color }} />
305
+ {icon}
306
+ </button>
307
+ {open ? (
308
+ <div className="lexical-toolbar-color__popover" ref={popoverRef}>
309
+ <input
310
+ type="color"
311
+ value={color}
312
+ onChange={(event) => onChange(event.target.value)}
313
+ />
314
+ <button
315
+ type="button"
316
+ className="lexical-toolbar-btn lexical-toolbar-btn--small"
317
+ onClick={() => {
318
+ onClear()
319
+ setOpen(false)
320
+ }}>
321
+
322
+ </button>
323
+ </div>
324
+ ) : null}
325
+ </div>
326
+ )
327
+ }
328
+
329
+
330
+ import LinkPopup from "./LinkPopup"
331
+
332
+ function Toolbar() {
333
+ const [editor] = useLexicalComposerContext()
334
+ const [formats, setFormats] = useState({
335
+ bold: false,
336
+ italic: false,
337
+ underline: false,
338
+ strike: false
339
+ })
340
+ const [isCodeBlock, setIsCodeBlock] = useState(false)
341
+ const [canUndo, setCanUndo] = useState(false)
342
+ const [canRedo, setCanRedo] = useState(false)
343
+ const imageInputRef = useRef(null)
344
+ const fileInputRef = useRef(null)
345
+ const DEFAULT_FONT_COLOR = "#000000"
346
+ const DEFAULT_BG_COLOR = "#ffffff"
347
+ const [fontColor, setFontColor] = useState(DEFAULT_FONT_COLOR)
348
+ const [bgColor, setBgColor] = useState(DEFAULT_BG_COLOR)
349
+ const [showLinkPopup, setShowLinkPopup] = useState(false)
350
+ const [linkPopupData, setLinkPopupData] = useState({ label: "", url: "" })
351
+
352
+ const handleFiles = useCallback(
353
+ (fileList, options = {}) => {
354
+ if (!fileList) return
355
+ Array.from(fileList).forEach((file) => {
356
+ if (file) {
357
+ // Use INSERT_FILE_COMMAND which handles both images and files
358
+ editor.dispatchCommand(INSERT_FILE_COMMAND, {
359
+ file,
360
+ options
361
+ })
362
+ }
363
+ })
364
+ },
365
+ [editor]
366
+ )
367
+
368
+ const openImagePicker = useCallback(() => {
369
+ imageInputRef.current?.click()
370
+ }, [])
371
+
372
+ const openFilePicker = useCallback(() => {
373
+ fileInputRef.current?.click()
374
+ }, [])
375
+
376
+ const refreshFormats = useCallback(() => {
377
+ const selection = $getSelection()
378
+ if (!$isRangeSelection(selection)) {
379
+ setIsCodeBlock(false)
380
+ return
381
+ }
382
+ setFormats({
383
+ bold: selection.hasFormat("bold"),
384
+ italic: selection.hasFormat("italic"),
385
+ underline: selection.hasFormat("underline"),
386
+ strike: selection.hasFormat("strikethrough")
387
+ })
388
+ const anchor = selection.anchor.getNode()
389
+ const topLevel = anchor.getTopLevelElement()
390
+ setIsCodeBlock(Boolean(topLevel && $isCodeNode(topLevel)))
391
+ }, [])
392
+
393
+ useEffect(() => {
394
+ return editor.registerCommand(
395
+ SELECTION_CHANGE_COMMAND,
396
+ () => {
397
+ editor.getEditorState().read(refreshFormats)
398
+ return false
399
+ },
400
+ COMMAND_PRIORITY_LOW
401
+ )
402
+ }, [editor, refreshFormats])
403
+
404
+ useEffect(() => {
405
+ return editor.registerCommand(
406
+ CAN_UNDO_COMMAND,
407
+ (payload) => {
408
+ setCanUndo(payload)
409
+ return false
410
+ },
411
+ COMMAND_PRIORITY_CRITICAL
412
+ )
413
+ }, [editor])
414
+
415
+ useEffect(() => {
416
+ return editor.registerCommand(
417
+ CAN_REDO_COMMAND,
418
+ (payload) => {
419
+ setCanRedo(payload)
420
+ return false
421
+ },
422
+ COMMAND_PRIORITY_CRITICAL
423
+ )
424
+ }, [editor])
425
+
426
+ useEffect(() => {
427
+ return editor.registerUpdateListener(({ editorState }) => {
428
+ editorState.read(refreshFormats)
429
+ })
430
+ }, [editor, refreshFormats])
431
+
432
+ const toggleFormat = useCallback(
433
+ (type) => {
434
+ editor.dispatchCommand(FORMAT_TEXT_COMMAND, type)
435
+ },
436
+ [editor]
437
+ )
438
+
439
+ const toggleList = useCallback(
440
+ (type) => {
441
+ const command =
442
+ type === "number" ? INSERT_ORDERED_LIST_COMMAND : INSERT_UNORDERED_LIST_COMMAND
443
+ editor.dispatchCommand(command)
444
+ },
445
+ [editor]
446
+ )
447
+
448
+ const toggleLink = useCallback(() => {
449
+ let hasLink = false
450
+ let selectionText = ""
451
+ let isRange = false
452
+ let url = ""
453
+
454
+ editor.getEditorState().read(() => {
455
+ const selection = $getSelection()
456
+ if (!$isRangeSelection(selection)) return
457
+
458
+ isRange = true
459
+ selectionText = selection.getTextContent()
460
+
461
+ const nodes = selection.getNodes()
462
+ const nodeWithLink = nodes.find((node) => {
463
+ if (node.getType() === "link") return true
464
+ const parent = node.getParent()
465
+ return parent?.getType() === "link"
466
+ })
467
+
468
+ if (nodeWithLink) {
469
+ hasLink = true
470
+ const linkNode = nodeWithLink.getType() === "link" ? nodeWithLink : nodeWithLink.getParent()
471
+ url = linkNode.getURL()
472
+ }
473
+ })
474
+
475
+ if (!isRange) return
476
+
477
+ setLinkPopupData({ label: selectionText, url: url })
478
+ setShowLinkPopup(true)
479
+ }, [editor])
480
+
481
+ const applyTextStyle = useCallback(
482
+ (style) => {
483
+ editor.update(() => {
484
+ const selection = $getSelection()
485
+ if ($isRangeSelection(selection)) {
486
+ $patchStyleText(selection, style)
487
+ }
488
+ })
489
+ },
490
+ [editor]
491
+ )
492
+
493
+ const toggleCodeBlock = useCallback(() => {
494
+ editor.update(() => {
495
+ const selection = $getSelection()
496
+ if (!$isRangeSelection(selection)) return
497
+
498
+ const anchorNode = selection.anchor.getNode()
499
+ const topLevel = anchorNode.getTopLevelElement()
500
+ if (!topLevel) return
501
+
502
+ if ($isCodeNode(topLevel)) {
503
+ const textContent = topLevel.getTextContent()
504
+ const lines = textContent.split("\n")
505
+ const firstParagraph = $createParagraphNode()
506
+ firstParagraph.append($createTextNode(lines[0] || ""))
507
+ topLevel.replace(firstParagraph)
508
+ let previous = firstParagraph
509
+ for (let index = 1; index < lines.length; index += 1) {
510
+ const paragraph = $createParagraphNode()
511
+ paragraph.append($createTextNode(lines[index]))
512
+ previous.insertAfter(paragraph)
513
+ previous = paragraph
514
+ }
515
+ firstParagraph.selectEnd()
516
+ return
517
+ }
518
+
519
+ if (topLevel.getType?.() !== "paragraph") {
520
+ return
521
+ }
522
+
523
+ const codeNode = $createCodeNode()
524
+ const content = topLevel.getTextContent()
525
+ codeNode.append($createTextNode(content || ""))
526
+ topLevel.replace(codeNode)
527
+ codeNode.selectEnd()
528
+ })
529
+ }, [editor])
530
+
531
+ return (
532
+ <div className="lexical-toolbar">
533
+ <button
534
+ type="button"
535
+ className="lexical-toolbar-btn"
536
+ onClick={() => editor.dispatchCommand(UNDO_COMMAND, undefined)}
537
+ disabled={!canUndo}
538
+ title="Undo (⌘/Ctrl+Z)"
539
+ aria-label="Undo">
540
+
541
+ </button>
542
+ <button
543
+ type="button"
544
+ className="lexical-toolbar-btn"
545
+ onClick={() => editor.dispatchCommand(REDO_COMMAND, undefined)}
546
+ disabled={!canRedo}
547
+ title="Redo (⇧⌘/Ctrl+Z)"
548
+ aria-label="Redo">
549
+
550
+ </button>
551
+ <span className="lexical-toolbar-separator" aria-hidden="true" />
552
+ <button
553
+ type="button"
554
+ className={`lexical-toolbar-btn ${formats.bold ? "active" : ""}`}
555
+ onClick={() => toggleFormat("bold")}
556
+ title="Bold (⌘/Ctrl+B)">
557
+ B
558
+ </button>
559
+ <button
560
+ type="button"
561
+ className={`lexical-toolbar-btn ${formats.italic ? "active" : ""}`}
562
+ onClick={() => toggleFormat("italic")}
563
+ title="Italic (⌘/Ctrl+I)">
564
+ I
565
+ </button>
566
+ <button
567
+ type="button"
568
+ className={`lexical-toolbar-btn ${formats.underline ? "active" : ""}`}
569
+ onClick={() => toggleFormat("underline")}
570
+ title="Underline (⌘/Ctrl+U)">
571
+ U
572
+ </button>
573
+ <button
574
+ type="button"
575
+ className={`lexical-toolbar-btn ${formats.strike ? "active" : ""}`}
576
+ onClick={() => toggleFormat("strikethrough")}
577
+ title="Strikethrough">
578
+ S
579
+ </button>
580
+ <button
581
+ type="button"
582
+ className={`lexical-toolbar-btn ${isCodeBlock ? "active" : ""}`}
583
+ onClick={toggleCodeBlock}
584
+ title="Code block">
585
+ {'</>'}
586
+ </button>
587
+ <span className="lexical-toolbar-separator" aria-hidden="true" />
588
+ <button
589
+ type="button"
590
+ className="lexical-toolbar-btn"
591
+ onClick={() => toggleList("bullet")}
592
+ title="Bulleted list">
593
+ ••
594
+ </button>
595
+ <button
596
+ type="button"
597
+ className="lexical-toolbar-btn"
598
+ onClick={() => toggleList("number")}
599
+ title="Numbered list">
600
+ 1.
601
+ </button>
602
+ <span className="lexical-toolbar-separator" aria-hidden="true" />
603
+ <button
604
+ type="button"
605
+ className="lexical-toolbar-btn"
606
+ onClick={toggleLink}
607
+ title="Insert link">
608
+ 🔗
609
+ </button>
610
+ <span className="lexical-toolbar-separator" aria-hidden="true" />
611
+ <ToolbarColorPicker
612
+ icon="🎨"
613
+ title="Text color"
614
+ color={fontColor}
615
+ onChange={(value) => {
616
+ setFontColor(value)
617
+ applyTextStyle({ color: value })
618
+ }}
619
+ onClear={() => {
620
+ setFontColor(DEFAULT_FONT_COLOR)
621
+ applyTextStyle({ color: "" })
622
+ }}
623
+ />
624
+ <ToolbarColorPicker
625
+ icon="🖌️"
626
+ title="Background color"
627
+ color={bgColor}
628
+ onChange={(value) => {
629
+ setBgColor(value)
630
+ applyTextStyle({ "background-color": value })
631
+ }}
632
+ onClear={() => {
633
+ setBgColor(DEFAULT_BG_COLOR)
634
+ applyTextStyle({ "background-color": "" })
635
+ }}
636
+ />
637
+ <span className="lexical-toolbar-separator" aria-hidden="true" />
638
+ <input
639
+ ref={imageInputRef}
640
+ type="file"
641
+ accept="image/*"
642
+ style={{ display: "none" }}
643
+ onChange={(event) => {
644
+ handleFiles(event.target.files, { kind: "image" })
645
+ event.target.value = ""
646
+ }}
647
+ />
648
+ <input
649
+ ref={fileInputRef}
650
+ type="file"
651
+ style={{ display: "none" }}
652
+ onChange={(event) => {
653
+ handleFiles(event.target.files)
654
+ event.target.value = ""
655
+ }}
656
+ />
657
+ <button
658
+ type="button"
659
+ className="lexical-toolbar-btn"
660
+ onClick={openImagePicker}
661
+ title="Insert image">
662
+ 🖼️
663
+ </button>
664
+ <button
665
+ type="button"
666
+ className="lexical-toolbar-btn"
667
+ onClick={openFilePicker}
668
+ title="Attach file">
669
+ 📎
670
+ </button>
671
+ {showLinkPopup && (
672
+ <LinkPopup
673
+ initialLabel={linkPopupData.label}
674
+ initialUrl={linkPopupData.url}
675
+ onConfirm={(label, url) => {
676
+ const finalUrl = url?.trim()
677
+ if (!finalUrl) {
678
+ setShowLinkPopup(false)
679
+ return
680
+ }
681
+ const finalLabel = (label || "").trim() || finalUrl
682
+
683
+ editor.update(() => {
684
+ const selection = $getSelection()
685
+ if (!selection) return
686
+
687
+ // Check if we are editing an existing link
688
+ const nodes = selection.getNodes()
689
+ const nodeWithLink = nodes.find((node) => {
690
+ if (node.getType() === "link") return true
691
+ const parent = node.getParent()
692
+ return parent?.getType() === "link"
693
+ })
694
+
695
+ if (nodeWithLink) {
696
+ // UPDATE MODE
697
+ const linkNode = nodeWithLink.getType() === "link" ? nodeWithLink : nodeWithLink.getParent()
698
+ const newLink = $createLinkNode(finalUrl)
699
+ newLink.append($createTextNode(finalLabel))
700
+ linkNode.replace(newLink)
701
+ newLink.select()
702
+ } else {
703
+ // CREATE MODE
704
+ const newLink = $createLinkNode(finalUrl)
705
+ newLink.append($createTextNode(finalLabel))
706
+
707
+ if ($isRangeSelection(selection) && !selection.isCollapsed()) {
708
+ // Replace selection
709
+ selection.insertNodes([newLink])
710
+ } else {
711
+ // Insert at cursor
712
+ selection.insertNodes([newLink])
713
+ }
714
+ // Explicitly select the new link to ensure selection validity
715
+ newLink.select()
716
+ }
717
+ })
718
+
719
+ setShowLinkPopup(false)
720
+ }}
721
+ onCancel={() => setShowLinkPopup(false)}
722
+ />
723
+ )}
724
+ </div>
725
+ )
726
+ }
727
+
728
+ function ReadyPlugin({ onReady }) {
729
+ const [editor] = useLexicalComposerContext()
730
+
731
+ useEffect(() => {
732
+ if (!onReady) return
733
+ onReady({
734
+ focus: () => {
735
+ editor.focus(() => {
736
+ editor.getRootElement()?.focus()
737
+ })
738
+ },
739
+ getEditor: () => editor
740
+ })
741
+ }, [editor, onReady])
742
+
743
+ return null
744
+ }
745
+
746
+ function EditorInner({
747
+ initialHtml,
748
+ onChange,
749
+ onKeyDown,
750
+ onReady,
751
+ onUploadStateChange,
752
+ directUploadUrl,
753
+ blobUrlTemplate,
754
+ placeholderText,
755
+ deletedAttachmentsRef
756
+ }) {
757
+ const [editor] = useLexicalComposerContext()
758
+ const handleDrop = useCallback(
759
+ (event) => {
760
+ const files = event.dataTransfer?.files
761
+ if (!files || files.length === 0) return
762
+ event.preventDefault()
763
+ Array.from(files).forEach((file) => {
764
+ if (!file) return
765
+ editor.dispatchCommand(INSERT_FILE_COMMAND, { file })
766
+ })
767
+ },
768
+ [editor]
769
+ )
770
+
771
+ const handleDragOver = useCallback((event) => {
772
+ if (event.dataTransfer?.types?.includes("Files")) {
773
+ event.preventDefault()
774
+ }
775
+ }, [])
776
+
777
+ return (
778
+ <div className="lexical-editor-shell">
779
+ <Toolbar />
780
+ <div className="lexical-editor-inner">
781
+ <RichTextPlugin
782
+ contentEditable={
783
+ <ContentEditable
784
+ className="lexical-content-editable shared-input-surface"
785
+ onKeyDown={(event) => {
786
+ if (!onKeyDown) return
787
+ onKeyDown(event, editor)
788
+ }}
789
+ onDrop={handleDrop}
790
+ onDragOver={handleDragOver}
791
+ />
792
+ }
793
+ placeholder={<Placeholder text={placeholderText} />}
794
+ ErrorBoundary={LexicalErrorBoundary}
795
+ />
796
+ <HistoryPlugin />
797
+ <CodeHighlightingPlugin />
798
+ <ListPlugin />
799
+ <LinkPlugin />
800
+ <AutoLinkPlugin matchers={URL_MATCHERS} />
801
+ <OnChangePlugin
802
+ onChange={(editorState, editorInstance) => {
803
+ if (!onChange) return
804
+ let serialized = ""
805
+ editorState.read(() => {
806
+ const innerHtml = $generateHtmlFromNodes(editorInstance)
807
+ const parser = new DOMParser()
808
+ const doc = parser.parseFromString(`<div>${innerHtml}</div>`, "text/html")
809
+
810
+ const rootElement = editorInstance.getRootElement()
811
+
812
+ syncLexicalStyleAttributes(doc.body)
813
+ updateResponsiveImages(doc.body, rootElement?.clientWidth)
814
+
815
+ doc.querySelectorAll("a").forEach((anchor) => {
816
+ anchor.setAttribute("target", "_blank")
817
+ anchor.setAttribute("rel", "noopener")
818
+ })
819
+ serialized = doc.body.innerHTML
820
+ })
821
+ // No Trix wrapper
822
+ onChange(serialized)
823
+ }}
824
+ />
825
+ <InitialContentPlugin html={initialHtml} />
826
+ <LinkAttributesPlugin />
827
+ <ReadyPlugin onReady={onReady} />
828
+ <FileUploadPlugin
829
+ onUploadStateChange={onUploadStateChange}
830
+ directUploadUrl={directUploadUrl}
831
+ blobUrlTemplate={blobUrlTemplate}
832
+ />
833
+ <AttachmentCleanupPlugin deletedAttachmentsRef={deletedAttachmentsRef} />
834
+ </div>
835
+ </div>
836
+ )
837
+ }
838
+
839
+ export default function InlineLexicalEditor({
840
+ initialHtml,
841
+ onChange,
842
+ onKeyDown,
843
+ onReady,
844
+ onUploadStateChange,
845
+ directUploadUrl,
846
+ blobUrlTemplate,
847
+ editorKey,
848
+ placeholderText,
849
+ deletedAttachmentsRef
850
+ }) {
851
+ const initialConfig = useMemo(
852
+ () => ({
853
+ namespace: "CreativeLexicalEditor",
854
+ nodes: [
855
+ HeadingNode,
856
+ QuoteNode,
857
+ CodeNode,
858
+ CodeHighlightNode,
859
+ ListItemNode,
860
+ ListNode,
861
+ LinkNode,
862
+ AutoLinkNode,
863
+ ImageNode,
864
+ AttachmentNode
865
+ ],
866
+ onError(error) {
867
+ throw error
868
+ },
869
+ theme
870
+ }),
871
+ []
872
+ )
873
+
874
+ return (
875
+ <LexicalComposer key={editorKey} initialConfig={initialConfig}>
876
+ <EditorInner
877
+ initialHtml={initialHtml}
878
+ onChange={onChange}
879
+ onKeyDown={onKeyDown}
880
+ onReady={onReady}
881
+ onUploadStateChange={onUploadStateChange}
882
+ directUploadUrl={directUploadUrl}
883
+ blobUrlTemplate={blobUrlTemplate}
884
+ placeholderText={placeholderText}
885
+ deletedAttachmentsRef={deletedAttachmentsRef}
886
+ />
887
+ </LexicalComposer>
888
+ )
889
+ }