collavre 0.5.0 → 0.7.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 (133) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/collavre/comment_versions.css +76 -0
  3. data/app/assets/stylesheets/collavre/comments_popup.css +347 -37
  4. data/app/assets/stylesheets/collavre/creatives.css +73 -1
  5. data/app/assets/stylesheets/collavre/org_chart.css +319 -0
  6. data/app/assets/stylesheets/collavre/popup.css +68 -1
  7. data/app/controllers/collavre/application_controller.rb +13 -0
  8. data/app/controllers/collavre/comments/versions_controller.rb +82 -0
  9. data/app/controllers/collavre/comments_controller.rb +14 -153
  10. data/app/controllers/collavre/concerns/exportable.rb +30 -0
  11. data/app/controllers/collavre/concerns/shareable.rb +28 -0
  12. data/app/controllers/collavre/concerns/slide_viewable.rb +37 -0
  13. data/app/controllers/collavre/concerns/tree_manageable.rb +141 -0
  14. data/app/controllers/collavre/creative_imports_controller.rb +6 -0
  15. data/app/controllers/collavre/creative_invitations_controller.rb +46 -0
  16. data/app/controllers/collavre/creative_plans_controller.rb +1 -1
  17. data/app/controllers/collavre/creative_shares_controller.rb +84 -14
  18. data/app/controllers/collavre/creatives_controller.rb +70 -194
  19. data/app/controllers/collavre/google_auth_controller.rb +3 -0
  20. data/app/controllers/collavre/invites_controller.rb +2 -1
  21. data/app/controllers/collavre/sessions_controller.rb +3 -0
  22. data/app/controllers/collavre/topics_controller.rb +39 -2
  23. data/app/controllers/collavre/users_controller.rb +5 -404
  24. data/app/controllers/concerns/collavre/comments/approval_actions.rb +108 -0
  25. data/app/controllers/concerns/collavre/comments/batch_operations.rb +55 -0
  26. data/app/controllers/concerns/collavre/comments/conversion.rb +46 -0
  27. data/app/controllers/concerns/collavre/users_controller/admin_operations.rb +74 -0
  28. data/app/controllers/concerns/collavre/users_controller/ai_user_management.rb +119 -0
  29. data/app/controllers/concerns/collavre/users_controller/contact_management.rb +166 -0
  30. data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +102 -0
  31. data/app/controllers/concerns/collavre/users_controller/registration.rb +63 -0
  32. data/app/helpers/collavre/application_helper.rb +1 -0
  33. data/app/helpers/collavre/creatives_helper.rb +12 -9
  34. data/app/helpers/collavre/navigation_helper.rb +1 -1
  35. data/app/javascript/collavre.js +0 -1
  36. data/app/javascript/controllers/comment_controller.js +33 -70
  37. data/app/javascript/controllers/comment_version_controller.js +164 -0
  38. data/app/javascript/controllers/comments/__tests__/form_controller_review.test.js +305 -0
  39. data/app/javascript/controllers/comments/__tests__/list_controller_selection.test.js +103 -0
  40. data/app/javascript/controllers/comments/__tests__/review_quotes_store.test.js +113 -0
  41. data/app/javascript/controllers/comments/contexts_controller.js +363 -0
  42. data/app/javascript/controllers/comments/form_controller.js +304 -13
  43. data/app/javascript/controllers/comments/list_controller.js +151 -62
  44. data/app/javascript/controllers/comments/popup_controller.js +66 -38
  45. data/app/javascript/controllers/comments/presence_controller.js +2 -10
  46. data/app/javascript/controllers/comments/review_quotes_store.js +189 -0
  47. data/app/javascript/controllers/comments/topics_controller.js +34 -10
  48. data/app/javascript/controllers/index.js +15 -1
  49. data/app/javascript/controllers/org_chart_controller.js +46 -0
  50. data/app/javascript/controllers/share_modal_controller.js +369 -0
  51. data/app/javascript/controllers/topic_search_controller.js +103 -0
  52. data/app/javascript/creatives/drag_drop/event_handlers.js +42 -1
  53. data/app/javascript/lib/api/creatives.js +12 -0
  54. data/app/javascript/lib/api/csrf_fetch.js +35 -0
  55. data/app/javascript/lib/api/drag_drop.js +17 -0
  56. data/app/javascript/modules/command_menu.js +40 -0
  57. data/app/javascript/modules/creative_row_editor.js +88 -0
  58. data/app/javascript/modules/slide_view.js +2 -1
  59. data/app/jobs/collavre/ai_agent_job.rb +42 -30
  60. data/app/jobs/collavre/compress_job.rb +92 -0
  61. data/app/models/collavre/comment.rb +36 -1
  62. data/app/models/collavre/comment_version.rb +15 -0
  63. data/app/models/collavre/creative/describable.rb +1 -1
  64. data/app/models/collavre/creative.rb +51 -0
  65. data/app/models/collavre/task.rb +30 -2
  66. data/app/models/collavre/user.rb +20 -3
  67. data/app/services/collavre/ai_agent/a2a_dispatcher.rb +68 -0
  68. data/app/services/collavre/ai_agent/agent_lifecycle_manager.rb +89 -0
  69. data/app/services/collavre/ai_agent/message_builder.rb +85 -6
  70. data/app/services/collavre/ai_agent/response_finalizer.rb +97 -0
  71. data/app/services/collavre/ai_agent/response_streamer.rb +56 -0
  72. data/app/services/collavre/ai_agent/review_handler.rb +18 -1
  73. data/app/services/collavre/ai_agent_service.rb +130 -183
  74. data/app/services/collavre/ai_client.rb +6 -0
  75. data/app/services/collavre/auto_theme_generator.rb +1 -1
  76. data/app/services/collavre/command_menu_service.rb +19 -0
  77. data/app/services/collavre/comments/command_processor.rb +3 -1
  78. data/app/services/collavre/comments/compress_command.rb +75 -0
  79. data/app/services/collavre/comments/concerns/workflow_support.rb +115 -0
  80. data/app/services/collavre/comments/work_command.rb +161 -0
  81. data/app/services/collavre/comments/workflow_executor.rb +276 -0
  82. data/app/services/collavre/creatives/plan_tagger.rb +14 -3
  83. data/app/services/collavre/creatives/tree_formatter.rb +53 -13
  84. data/app/services/collavre/gemini_parent_recommender.rb +4 -4
  85. data/app/services/collavre/orchestration/agent_context_builder.rb +1 -3
  86. data/app/services/collavre/orchestration/agent_orchestrator.rb +15 -4
  87. data/app/services/collavre/orchestration/policy_resolver.rb +0 -19
  88. data/app/services/collavre/orchestration/scheduler.rb +3 -2
  89. data/app/services/collavre/orchestration/stuck_detector.rb +1 -1
  90. data/app/services/collavre/system_events/dispatcher.rb +9 -0
  91. data/app/services/collavre/tools/creative_create_service.rb +1 -8
  92. data/app/services/collavre/tools/creative_import_service.rb +46 -0
  93. data/app/services/collavre/tools/creative_retrieval_service.rb +157 -96
  94. data/app/services/collavre/tools/creative_update_service.rb +1 -8
  95. data/app/services/collavre/tools/cron_list_service.rb +1 -1
  96. data/app/services/collavre/tools/description_normalizable.rb +16 -0
  97. data/app/views/collavre/comments/_comment.html.erb +25 -8
  98. data/app/views/collavre/comments/_comments_popup.html.erb +32 -5
  99. data/app/views/collavre/creatives/_inline_edit_form.html.erb +13 -0
  100. data/app/views/collavre/creatives/_share_button.html.erb +4 -1
  101. data/app/views/collavre/creatives/_share_modal.html.erb +31 -1
  102. data/app/views/collavre/creatives/index.html.erb +5 -5
  103. data/app/views/collavre/creatives/slide_view.html.erb +1 -1
  104. data/app/views/collavre/users/{_contact_management.html.erb → _contact_list.html.erb} +4 -8
  105. data/app/views/collavre/users/_org_chart.html.erb +68 -0
  106. data/app/views/collavre/users/_org_chart_node.html.erb +169 -0
  107. data/app/views/collavre/users/new_ai.html.erb +9 -0
  108. data/app/views/collavre/users/show.html.erb +32 -8
  109. data/config/locales/comments.en.yml +57 -2
  110. data/config/locales/comments.ko.yml +57 -2
  111. data/config/locales/contacts.en.yml +31 -0
  112. data/config/locales/contacts.ko.yml +31 -0
  113. data/config/locales/contexts.en.yml +8 -0
  114. data/config/locales/contexts.ko.yml +8 -0
  115. data/config/locales/creatives.en.yml +6 -0
  116. data/config/locales/creatives.ko.yml +6 -0
  117. data/config/locales/users.en.yml +1 -0
  118. data/config/locales/users.ko.yml +1 -0
  119. data/config/routes.rb +14 -1
  120. data/db/migrate/20260220072200_add_workflow_fields_to_tasks.rb +12 -0
  121. data/db/migrate/20260223173533_add_review_type_to_comments.rb +5 -0
  122. data/db/migrate/20260225065200_create_comment_versions.rb +14 -0
  123. data/db/migrate/20260225074416_add_selected_version_id_to_comments.rb +7 -0
  124. data/lib/collavre/version.rb +1 -1
  125. metadata +47 -10
  126. data/app/javascript/lib/lexical/__tests__/action_text_attachment_node.test.jsx +0 -91
  127. data/app/javascript/lib/lexical/action_text_attachment_node.js +0 -459
  128. data/app/javascript/lib/lexical/dom_attachment_utils.js +0 -66
  129. data/app/javascript/modules/share_modal.js +0 -76
  130. data/app/javascript/modules/share_user_popup.js +0 -77
  131. data/app/services/collavre/orchestration/self_reflection_evaluator.rb +0 -231
  132. data/app/views/collavre/comments/_presence_avatars.html.erb +0 -8
  133. data/app/views/collavre/creatives/_delete_button.html.erb +0 -12
@@ -3,6 +3,8 @@ import { copyTextToClipboard } from '../../utils/clipboard'
3
3
  import { renderMarkdownInContainer } from '../../lib/utils/markdown'
4
4
  import creativesApi from '../../lib/api/creatives'
5
5
  import { renderCreativeTree, dispatchCreativeTreeUpdated } from '../../creatives/tree_renderer'
6
+ import { updateCsrfTokenFromResponse } from '../../lib/api/csrf_fetch'
7
+ // CommonPopup is now used via TopicSearchController (Stimulus)
6
8
 
7
9
  export default class extends Controller {
8
10
  static targets = ['list']
@@ -138,6 +140,7 @@ export default class extends Controller {
138
140
 
139
141
  loadInitialComments() {
140
142
  if (!this.creativeId) return
143
+ if (this.selection.size > 0) return
141
144
 
142
145
  const params = {}
143
146
  if (this.highlightAfterLoad) {
@@ -170,6 +173,7 @@ export default class extends Controller {
170
173
  this.initialLoadComplete = true
171
174
  this.formController?.focusTextarea()
172
175
  this.markCommentsRead()
176
+
173
177
  })
174
178
  }
175
179
 
@@ -239,6 +243,10 @@ export default class extends Controller {
239
243
  urlParams.set('topic_id', this.currentTopicId)
240
244
  }
241
245
  return fetch(`/creatives/${this.creativeId}/comments?${urlParams.toString()}`).then((response) => {
246
+ // Keep the CSRF meta tag in sync with the session cookie.
247
+ // This is critical after the browser returns from a background/frozen state.
248
+ updateCsrfTokenFromResponse(response)
249
+
242
250
  const serverTopicId = response.headers.get("X-Topic-Id")
243
251
  if (serverTopicId !== null && serverTopicId !== undefined) {
244
252
  // Server says we are in this topic.
@@ -382,11 +390,6 @@ export default class extends Controller {
382
390
  this.closeActionEditor(this.getActionContainer(target.closest('.cancel-comment-action-edit-btn')))
383
391
  return
384
392
  }
385
- if (target.classList.contains('delete-comment-btn')) {
386
- event.preventDefault()
387
- this.deleteComment(target)
388
- return
389
- }
390
393
  if (target.classList.contains('convert-comment-btn')) {
391
394
  event.preventDefault()
392
395
  this.convertComment(target)
@@ -488,64 +491,129 @@ export default class extends Controller {
488
491
  }
489
492
  this.updateDraggableState()
490
493
  this.notifySelectionChange()
491
- this.updateSelectionHint()
494
+ this.updateSelectionActionBar()
492
495
  }
493
496
 
494
- updateSelectionHint() {
495
- // Remove existing hint
496
- const existingHint = document.querySelector('.selection-hint-popup')
497
- if (existingHint) {
498
- existingHint.remove()
499
- }
497
+ updateSelectionActionBar() {
498
+ // Remove existing bar
499
+ const existing = document.querySelector('.selection-action-bar')
500
+ if (existing) existing.remove()
500
501
 
501
502
  if (this.selection.size === 0) return
502
503
 
503
- // Find the first selected checkbox
504
- const firstSelected = this.listTarget.querySelector('.comment-item.selected-for-move')
505
- if (!firstSelected) return
506
-
507
- const checkbox = firstSelected.querySelector('.comment-select-checkbox')
508
- if (!checkbox) return
509
-
510
- // Create hint popup
511
- const hint = document.createElement('div')
512
- hint.className = 'selection-hint-popup'
513
- const dragTopicText = this.element.dataset.hintDragTopicText || '🎯 Drag Move to topic'
514
- const moveButtonText = this.element.dataset.hintMoveButtonText || '📤 Move button → Another chat'
515
- hint.innerHTML = `
516
- <div class="hint-content">
517
- <span>${dragTopicText}</span>
518
- <span>${moveButtonText}</span>
504
+ const count = this.selection.size
505
+ const i18n = (key, fallback) => this.element.dataset[key] || fallback
506
+
507
+ const bar = document.createElement('div')
508
+ bar.className = 'selection-action-bar'
509
+ bar.innerHTML = `
510
+ <div class="selection-action-bar-main">
511
+ <span class="selection-action-bar-count">${i18n('selectionCountText', '{count}개 선택').replace('{count}', count)}</span>
512
+ <button type="button" class="selection-action-bar-btn selection-action-delete" title="${i18n('selectionDeleteText', 'Delete')}">🗑 ${i18n('selectionDeleteText', 'Delete')}</button>
513
+ <button type="button" class="selection-action-bar-btn selection-action-move" title="${i18n('selectionMoveText', 'Move')}">📤 ${i18n('selectionMoveText', 'Move')}</button>
514
+ <button type="button" class="selection-action-bar-btn selection-action-topic" title="${i18n('selectionTopicMoveText', 'Move to topic')}">🏷 ${i18n('selectionTopicMoveText', 'Move to topic')}</button>
515
+ <button type="button" class="selection-action-bar-close" title="${i18n('selectionCloseText', 'Cancel')}">✕</button>
516
+ </div>
517
+ <div class="selection-action-bar-hint no-touch">
518
+ 💡 ${i18n('selectionDragHintText', 'Drag & drop to move to topic')}
519
519
  </div>
520
520
  `
521
521
 
522
- // Append to body for proper positioning
523
- document.body.appendChild(hint)
522
+ bar.querySelector('.selection-action-delete').addEventListener('click', (e) => { e.stopPropagation(); this.deleteSelectedComments() })
523
+ bar.querySelector('.selection-action-move').addEventListener('click', (e) => this.openMoveModal(e))
524
+ bar.querySelector('.selection-action-topic').addEventListener('click', (e) => this.openTopicSearchPopup(e))
525
+ bar.querySelector('.selection-action-bar-close').addEventListener('click', () => this.clearSelection())
524
526
 
525
- // Position like CommonPopup - to the right of checkbox, within viewport
526
- requestAnimationFrame(() => {
527
- const checkboxRect = checkbox.getBoundingClientRect()
528
- const hintRect = hint.getBoundingClientRect()
529
- const boundsPadding = 8
527
+ // Insert before typing indicator so it stays inside the popup window
528
+ const typingIndicator = this.element.querySelector('#typing-indicator')
529
+ if (typingIndicator) {
530
+ typingIndicator.parentNode.insertBefore(bar, typingIndicator)
531
+ } else {
532
+ this.element.appendChild(bar)
533
+ }
534
+ }
535
+
536
+ async deleteSelectedComments() {
537
+ if (this.selection.size === 0) return
538
+ const confirmText = this.element.dataset.batchDeleteConfirmText || 'Are you sure you want to delete the selected messages?'
539
+ if (!confirm(confirmText)) return
540
+
541
+ const commentIds = Array.from(this.selection)
542
+ try {
543
+ const response = await fetch(`/creatives/${this.creativeId}/comments/batch_destroy`, {
544
+ method: 'DELETE',
545
+ headers: {
546
+ 'X-CSRF-Token': document.querySelector('meta[name=csrf-token]').content,
547
+ 'Content-Type': 'application/json',
548
+ },
549
+ body: JSON.stringify({ comment_ids: commentIds }),
550
+ })
551
+ if (response.ok) {
552
+ commentIds.forEach((id) => {
553
+ const el = document.getElementById(`comment_${id}`)
554
+ if (el) el.remove()
555
+ })
556
+ this.clearSelection()
557
+ } else {
558
+ const data = await response.json().catch(() => ({}))
559
+ alert(data.error || 'Failed to delete comments')
560
+ }
561
+ } catch (error) {
562
+ console.error('Error deleting comments:', error)
563
+ alert('Failed to delete comments')
564
+ }
565
+ }
566
+
567
+ openTopicSearchPopup(event) {
568
+ if (this.selection.size === 0) return
530
569
 
531
- // Start to the right of the checkbox, vertically centered
532
- let left = checkboxRect.right + 8
533
- let top = checkboxRect.top + (checkboxRect.height / 2) - (hintRect.height / 2)
570
+ const openWithController = (controller, btnRect) => {
571
+ controller.openForCreative(
572
+ this.creativeId,
573
+ btnRect,
574
+ (topic) => {
575
+ const commentIds = Array.from(this.selection)
576
+ this.handleMoveToTopic({ detail: { commentIds, targetTopicId: topic.id } })
577
+ },
578
+ this.element.dataset.topicMainText || 'Main'
579
+ )
580
+ }
534
581
 
535
- // Keep within viewport bounds
536
- const maxLeft = window.innerWidth - hintRect.width - boundsPadding
537
- const maxTop = window.innerHeight - hintRect.height - boundsPadding
582
+ const btnRect = event.currentTarget.getBoundingClientRect()
583
+ let modal = document.getElementById('topic-search-modal')
538
584
 
539
- // If overflows right, position to the left of checkbox instead
540
- if (left > maxLeft) {
541
- left = checkboxRect.left - hintRect.width - 8
585
+ if (modal) {
586
+ // Modal already exists — controller should be connected
587
+ const controller = this.application.getControllerForElementAndIdentifier(modal, 'topic-search')
588
+ if (controller) {
589
+ openWithController(controller, btnRect)
542
590
  }
543
-
544
- left = Math.max(boundsPadding, Math.min(left, maxLeft))
545
- top = Math.max(boundsPadding, Math.min(top, maxTop))
591
+ return
592
+ }
593
+
594
+ // First time: create modal and wait for Stimulus to connect
595
+ modal = document.createElement('div')
596
+ modal.id = 'topic-search-modal'
597
+ modal.className = 'common-popup'
598
+ modal.style.display = 'none'
599
+ modal.dataset.controller = 'topic-search'
600
+ modal.innerHTML = `
601
+ <button type="button" class="popup-close-btn" data-topic-search-target="close">&times;</button>
602
+ <input type="text" class="shared-input-surface" style="width:100%;margin-bottom:0.5em;"
603
+ placeholder="${this.element.dataset.topicSearchPlaceholderText || 'Search topics...'}"
604
+ data-topic-search-target="input">
605
+ <ul class="common-popup-list" data-popup-list data-topic-search-target="list"></ul>
606
+ `
607
+ document.body.appendChild(modal)
546
608
 
547
- hint.style.left = `${left}px`
548
- hint.style.top = `${top}px`
609
+ // Wait for Stimulus to connect the controller, then open
610
+ requestAnimationFrame(() => {
611
+ const controller = this.application.getControllerForElementAndIdentifier(modal, 'topic-search')
612
+ if (controller) {
613
+ openWithController(controller, btnRect)
614
+ } else {
615
+ console.error('topic-search controller not found after creation')
616
+ }
549
617
  })
550
618
  }
551
619
 
@@ -635,9 +703,9 @@ export default class extends Controller {
635
703
  if (item) item.classList.remove('selected-for-move')
636
704
  })
637
705
  this.notifySelectionChange()
638
- // Remove hint popup from body
639
- const hint = document.querySelector('.selection-hint-popup')
640
- if (hint) hint.remove()
706
+ // Remove action bar
707
+ const bar = document.querySelector('.selection-action-bar')
708
+ if (bar) bar.remove()
641
709
  }
642
710
 
643
711
  copyCommentLink(button) {
@@ -798,7 +866,7 @@ export default class extends Controller {
798
866
  }
799
867
 
800
868
  // Move Modal Logic
801
- openMoveModal() {
869
+ openMoveModal(event) {
802
870
  if (this.movingComments) return
803
871
  if (this.selection.size === 0) {
804
872
  alert(this.element.dataset.moveNoSelectionText || "No Selection")
@@ -806,16 +874,37 @@ export default class extends Controller {
806
874
  }
807
875
  this.movingComments = true
808
876
  this.notifySelectionChange()
809
- // ... assumed modal controller logic ...
877
+
878
+ // Reuse the existing link-creative-modal and its controller
810
879
  const modal = document.getElementById('link-creative-modal')
880
+ if (!modal) {
881
+ console.error('link-creative-modal not found')
882
+ this.movingComments = false
883
+ this.notifySelectionChange()
884
+ return
885
+ }
886
+
811
887
  const controller = this.application.getControllerForElementAndIdentifier(modal, 'link-creative')
812
- if (controller) {
813
- controller.open(this.element.getBoundingClientRect(),
814
- (item) => { this.moveSelectedComments(item.id) },
815
- () => { this.movingComments = false; this.notifySelectionChange() })
816
- } else {
817
- this.movingComments = false; this.notifySelectionChange()
888
+ if (!controller) {
889
+ console.error('link-creative controller not found')
890
+ this.movingComments = false
891
+ this.notifySelectionChange()
892
+ return
818
893
  }
894
+
895
+ const btnRect = event.currentTarget.getBoundingClientRect()
896
+ controller.open(
897
+ btnRect,
898
+ (item) => {
899
+ // onSelect — move comments to selected creative
900
+ this.moveSelectedComments(item.id)
901
+ },
902
+ () => {
903
+ // onClose
904
+ this.movingComments = false
905
+ this.notifySelectionChange()
906
+ }
907
+ )
819
908
  }
820
909
 
821
910
  moveSelectedComments(targetId) {
@@ -827,10 +916,10 @@ export default class extends Controller {
827
916
  body: JSON.stringify({ comment_ids: commentIds, target_creative_id: targetId })
828
917
  }).then(r => r.ok ? r.json() : Promise.reject())
829
918
  .then(() => {
830
- this.selection.clear()
919
+ this.clearSelection()
831
920
  this.loadInitialComments()
832
921
  })
833
- .finally(() => { this.movingComments = false; this.notifySelectionChange() })
922
+ .finally(() => { this.movingComments = false })
834
923
  }
835
924
 
836
925
  // UI Helpers
@@ -34,8 +34,10 @@ export default class extends Controller {
34
34
  this.handleWindowFocus = this.handleWindowFocus.bind(this)
35
35
  this.handleVisibilityChange = this.handleVisibilityChange.bind(this)
36
36
  this.handlePopState = this.handlePopState.bind(this)
37
+ this.handlePopupWheel = this.handlePopupWheel.bind(this)
37
38
 
38
39
  document.addEventListener(CREATIVE_CLICK_EVENT, this.handleCreativeClick)
40
+ this.element.addEventListener('wheel', this.handlePopupWheel, { passive: false })
39
41
  window.addEventListener('online', this.handleOnline)
40
42
  window.addEventListener('focus', this.handleWindowFocus)
41
43
  document.addEventListener('visibilitychange', this.handleVisibilityChange)
@@ -90,6 +92,7 @@ export default class extends Controller {
90
92
  disconnect() {
91
93
  this.clearPendingOpenFromUrl()
92
94
  document.removeEventListener(CREATIVE_CLICK_EVENT, this.handleCreativeClick)
95
+ this.element.removeEventListener('wheel', this.handlePopupWheel)
93
96
  window.removeEventListener('online', this.handleOnline)
94
97
  window.removeEventListener('focus', this.handleWindowFocus)
95
98
  document.removeEventListener('visibilitychange', this.handleVisibilityChange)
@@ -126,6 +129,10 @@ export default class extends Controller {
126
129
  return this.application.getControllerForElementAndIdentifier(this.element, 'comments--topics')
127
130
  }
128
131
 
132
+ get contextsController() {
133
+ return this.application.getControllerForElementAndIdentifier(this.element, 'comments--contexts')
134
+ }
135
+
129
136
  handleCreativeClick(event) {
130
137
  const button = event.detail?.button
131
138
  const creativeId = event.detail?.creativeId
@@ -154,7 +161,6 @@ export default class extends Controller {
154
161
 
155
162
  this.showPopup()
156
163
  this.updatePosition()
157
- document.body.classList.add('no-scroll')
158
164
 
159
165
  await this.notifyChildControllers({ creativeId: resolvedCreativeId, canComment, highlightId })
160
166
 
@@ -211,6 +217,9 @@ export default class extends Controller {
211
217
  if (this.mentionMenuController) {
212
218
  this.mentionMenuController.onPopupOpened({ creativeId })
213
219
  }
220
+ if (this.contextsController) {
221
+ this.contextsController.onPopupOpened({ creativeId })
222
+ }
214
223
  }
215
224
 
216
225
  close() {
@@ -229,6 +238,9 @@ export default class extends Controller {
229
238
  if (this.topicsController) {
230
239
  this.topicsController.onPopupClosed()
231
240
  }
241
+ if (this.contextsController) {
242
+ this.contextsController.onPopupClosed()
243
+ }
232
244
 
233
245
  // Dispatch event for integrations
234
246
  this.element.dispatchEvent(new CustomEvent('comments-popup:closed', {
@@ -247,7 +259,6 @@ export default class extends Controller {
247
259
  this.element.style.top = ''
248
260
  this.element.style.bottom = ''
249
261
  delete this.element.dataset.resized
250
- document.body.classList.remove('no-scroll')
251
262
  }
252
263
 
253
264
  prepareSize() {
@@ -284,12 +295,10 @@ export default class extends Controller {
284
295
  updatePosition() {
285
296
  if (this.isFullscreen() || !this.currentButton || this.isMobile() || this.element.dataset.resized === 'true') return
286
297
  const rect = this.currentButton.getBoundingClientRect()
287
- const scrollY = window.scrollY || window.pageYOffset
288
- let top = rect.bottom + scrollY + 4
298
+ let top = rect.bottom + 4
289
299
  const bottom = top + this.element.offsetHeight
290
- const viewportBottom = scrollY + window.innerHeight
291
- if (bottom > viewportBottom) {
292
- top = Math.max(scrollY + 4, viewportBottom - this.element.offsetHeight - 4)
300
+ if (bottom > window.innerHeight) {
301
+ top = Math.max(4, window.innerHeight - this.element.offsetHeight - 4)
293
302
  }
294
303
  this.element.style.top = `${top}px`
295
304
  this.element.style.right = `${window.innerWidth - rect.right + 24}px`
@@ -303,8 +312,8 @@ export default class extends Controller {
303
312
  this.resizeStartY = event.clientY
304
313
  this.startWidth = rect.width
305
314
  this.startHeight = rect.height
306
- this.startLeft = rect.left + window.scrollX
307
- this.startTop = rect.top + window.scrollY
315
+ this.startLeft = rect.left
316
+ this.startTop = rect.top
308
317
  this.startBottom = this.startTop + this.startHeight
309
318
  // this.reservedHeight = this.computeReservedHeight()
310
319
  this.element.style.left = `${this.startLeft}px`
@@ -403,6 +412,32 @@ export default class extends Controller {
403
412
  }
404
413
  }
405
414
 
415
+ // Prevent wheel events on the popup from scrolling the background creative list
416
+ handlePopupWheel(event) {
417
+ if (this.isFullscreen()) return // fullscreen already blocks body scroll via CSS
418
+
419
+ if (!this.hasListTarget) {
420
+ event.preventDefault()
421
+ return
422
+ }
423
+
424
+ const { scrollTop, scrollHeight, clientHeight } = this.listTarget
425
+ const isScrollingDown = event.deltaY > 0
426
+ const atTop = scrollTop <= 0
427
+ const atBottom = scrollTop + clientHeight >= scrollHeight - 1
428
+
429
+ // If the scrollable area has no overflow, or we're at the boundary, block propagation
430
+ if (scrollHeight <= clientHeight) {
431
+ event.preventDefault()
432
+ return
433
+ }
434
+
435
+ // At boundaries, prevent the event from reaching the background
436
+ if ((isScrollingDown && atBottom) || (!isScrollingDown && atTop)) {
437
+ event.preventDefault()
438
+ }
439
+ }
440
+
406
441
  // Enter fullscreen immediately without animation (for auto-fullscreen on page load)
407
442
  _enterFullscreenImmediate() {
408
443
  const el = this.element
@@ -535,17 +570,13 @@ export default class extends Controller {
535
570
  }
536
571
 
537
572
  // Desktop: animated exit to target position
538
- // Calculate target position using the same logic as updatePosition()
539
- // so cleanup can apply it directly without calling updatePosition() (which would cause a snap)
540
- const scrollY = window.scrollY || window.pageYOffset
541
-
542
- // Final absolute-position values (what updatePosition would set)
543
- let finalTop = '' // px string with scrollY included
573
+ // Calculate target position using viewport-relative coords (popup is position: fixed)
574
+ let finalTop = '' // px string (viewport-relative)
544
575
  let finalRight = '' // px string
545
576
  let finalWidth = savedStyles?.width || ''
546
577
  let finalHeight = savedStyles?.height || ''
547
578
 
548
- // Fixed-position animation targets (viewport-relative)
579
+ // Animation targets (viewport-relative, same as final since popup is fixed)
549
580
  let animTop, animLeft, animWidth, animHeight
550
581
 
551
582
  // Try to find the comment button for precise positioning
@@ -555,6 +586,15 @@ export default class extends Controller {
555
586
  targetButton = row?.querySelector('.comments-btn')
556
587
  }
557
588
 
589
+ // Scroll the creative row into view instantly BEFORE calculating positions,
590
+ // so getBoundingClientRect returns viewport-visible coordinates
591
+ if (targetButton) {
592
+ const row = targetButton.closest('creative-tree-row')
593
+ if (row) {
594
+ row.scrollIntoView({ behavior: 'instant', block: 'center' })
595
+ }
596
+ }
597
+
558
598
  if (targetButton) {
559
599
  this.currentButton = targetButton
560
600
  const btnRect = targetButton.getBoundingClientRect()
@@ -563,28 +603,25 @@ export default class extends Controller {
563
603
  animWidth = parseFloat(finalWidth) || 420
564
604
  animHeight = parseFloat(finalHeight) || 640
565
605
 
566
- // Calculate top in absolute coords (with scrollY) — same as updatePosition
567
- let absTop = btnRect.bottom + scrollY + 4
568
- const absBottom = absTop + animHeight
569
- const viewportBottom = scrollY + window.innerHeight
570
- if (absBottom > viewportBottom) {
571
- absTop = Math.max(scrollY + 4, viewportBottom - animHeight - 4)
606
+ // Calculate top in viewport coords — same as updatePosition
607
+ let top = btnRect.bottom + 4
608
+ const bottom = top + animHeight
609
+ if (bottom > window.innerHeight) {
610
+ top = Math.max(4, window.innerHeight - animHeight - 4)
572
611
  }
573
612
 
574
- // Store final absolute-position values for cleanup
575
- finalTop = `${absTop}px`
613
+ finalTop = `${top}px`
576
614
  finalRight = `${rightPx}px`
577
615
 
578
- // Convert to fixed coordinates for animation
579
- animTop = absTop - scrollY
616
+ animTop = top
580
617
  animLeft = window.innerWidth - rightPx - animWidth
581
618
  } else if (savedStyles && Object.values(savedStyles).some(v => v)) {
582
- // Fallback to saved styles
619
+ // Fallback to saved styles (already viewport-relative since popup is fixed)
583
620
  const rightVal = parseFloat(savedStyles.right) || 32
584
621
  animWidth = parseFloat(savedStyles.width) || 420
585
622
  animHeight = parseFloat(savedStyles.height) || 640
586
623
  animLeft = savedStyles.left ? parseFloat(savedStyles.left) : (window.innerWidth - rightVal - animWidth)
587
- animTop = parseFloat(savedStyles.top) ? (parseFloat(savedStyles.top) - scrollY) : 100
624
+ animTop = parseFloat(savedStyles.top) || 100
588
625
 
589
626
  finalTop = savedStyles.top || ''
590
627
  finalRight = savedStyles.right || ''
@@ -624,14 +661,12 @@ export default class extends Controller {
624
661
 
625
662
  const cleanup = () => {
626
663
  el.removeEventListener('transitionend', cleanup)
627
- // Switch from fixed back to default (absolute) positioning
628
- // Apply the pre-calculated absolute coords directly — no updatePosition() needed
664
+ // Popup is always position: fixed just apply final coords
629
665
  el.style.transition = 'none'
630
666
  el.style.position = ''
631
667
  el.style.bottom = ''
632
668
 
633
669
  if (targetButton) {
634
- // Set absolute coords matching updatePosition output
635
670
  el.style.top = finalTop
636
671
  el.style.right = finalRight
637
672
  el.style.left = ''
@@ -671,13 +706,6 @@ export default class extends Controller {
671
706
  }
672
707
  this._previousUrl = null
673
708
 
674
- // Scroll the selected creative row into view after exiting fullscreen
675
- if (creativeId) {
676
- requestAnimationFrame(() => {
677
- const row = document.querySelector(`creative-tree-row[creative-id="${creativeId}"]`)
678
- row?.scrollIntoView({ behavior: 'smooth', block: 'center' })
679
- })
680
- }
681
709
  }
682
710
 
683
711
  // Scroll to bottom after layout change
@@ -222,22 +222,14 @@ export default class extends Controller {
222
222
  addBtn.className = 'add-participant-btn'
223
223
  addBtn.textContent = '+'
224
224
  addBtn.title = this.element.dataset.addParticipantText || 'Add user'
225
- addBtn.addEventListener('click', () => this.openShareModal())
225
+ addBtn.dataset.action = 'click->share-modal#open'
226
+ addBtn.dataset.shareModalUrlParam = `/creatives/${this.creativeId}/creative_shares`
226
227
  this.participantsTarget.appendChild(addBtn)
227
228
  }
228
229
 
229
230
  this.updateReadReceiptPresence(presentIds)
230
231
  }
231
232
 
232
- openShareModal() {
233
- const modal = document.getElementById('share-creative-modal')
234
- if (modal) {
235
- modal.style.display = 'flex'
236
- document.body.classList.add('no-scroll')
237
- } else if (this.creativeId) {
238
- window.location.href = `/creatives/${this.creativeId}?open_comments=true&open_share=true`
239
- }
240
- }
241
233
 
242
234
  renderTypingIndicator() {
243
235
  if (!this.hasTypingIndicatorTarget) return