decidim-core 0.31.4 → 0.31.6

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 (140) hide show
  1. checksums.yaml +4 -4
  2. data/app/cells/decidim/amendable/amend_button_card/show.erb +2 -2
  3. data/app/cells/decidim/author_cell.rb +0 -4
  4. data/app/cells/decidim/content_blocks/cta_settings_form/show.erb +1 -1
  5. data/app/cells/decidim/content_blocks/hero_settings_form/show.erb +2 -2
  6. data/app/cells/decidim/content_blocks/highlighted_content_banner_cell.rb +1 -1
  7. data/app/cells/decidim/content_blocks/highlighted_content_banner_settings_form/show.erb +1 -1
  8. data/app/cells/decidim/content_blocks/highlighted_participatory_spaces_cell.rb +1 -1
  9. data/app/cells/decidim/content_blocks/html_cell.rb +1 -1
  10. data/app/cells/decidim/content_blocks/participatory_space_hero_settings_form/show.erb +1 -1
  11. data/app/cells/decidim/content_blocks/static_page/section_cell.rb +1 -1
  12. data/app/cells/decidim/content_blocks/static_page/section_settings_form/show.erb +3 -1
  13. data/app/cells/decidim/content_blocks/static_page/summary_cell.rb +1 -1
  14. data/app/cells/decidim/content_blocks/static_page/summary_settings_form/show.erb +3 -1
  15. data/app/cells/decidim/content_blocks/static_page/two_pane_section_cell.rb +2 -2
  16. data/app/cells/decidim/content_blocks/static_page/two_pane_section_settings_form/show.erb +4 -2
  17. data/app/cells/decidim/data_consent/category.erb +5 -5
  18. data/app/cells/decidim/resource_types_filter/show.erb +2 -2
  19. data/app/cells/decidim/upload_modal_cell.rb +5 -0
  20. data/app/commands/decidim/destroy_account.rb +12 -1
  21. data/app/commands/decidim/multiple_attachments_methods.rb +28 -27
  22. data/app/controllers/concerns/decidim/devise_controllers.rb +10 -0
  23. data/app/controllers/decidim/download_your_data_controller.rb +1 -1
  24. data/app/controllers/decidim/notifications_subscriptions_controller.rb +8 -0
  25. data/app/controllers/decidim/private_downloads_controller.rb +29 -0
  26. data/app/jobs/decidim/process_inactive_participant_job.rb +0 -7
  27. data/app/mailers/decidim/delete_user_mailer.rb +14 -0
  28. data/app/mailers/decidim/participants_account_mailer.rb +0 -16
  29. data/app/models/decidim/attachment.rb +20 -2
  30. data/app/models/decidim/authorization.rb +7 -0
  31. data/app/models/decidim/moderation.rb +1 -1
  32. data/app/models/decidim/participatory_space_private_user.rb +1 -1
  33. data/app/models/decidim/private_download.rb +61 -0
  34. data/app/models/decidim/private_export.rb +6 -0
  35. data/app/models/decidim/user_base_entity.rb +17 -2
  36. data/app/models/decidim/user_moderation.rb +1 -1
  37. data/app/packs/src/decidim/controllers/form_validator/form_validator.js +6 -6
  38. data/app/packs/src/decidim/controllers/form_validator/form_validator.test.js +23 -1
  39. data/app/packs/src/decidim/controllers/mention/controller.js +296 -140
  40. data/app/packs/src/decidim/controllers/mention/input_mentions.test.js +120 -457
  41. data/app/packs/src/decidim/controllers/multiple_mentions/controller.js +68 -32
  42. data/app/packs/src/decidim/controllers/multiple_mentions/input_multiple_mentions.test.js +30 -23
  43. data/app/packs/src/decidim/direct_uploads/upload_field.js +4 -4
  44. data/app/packs/src/decidim/direct_uploads/upload_modal.js +11 -7
  45. data/app/packs/src/decidim/geocoding/reverse_geocoding.js +15 -5
  46. data/app/packs/src/decidim/geocoding/reverse_geocoding.test.js +197 -0
  47. data/app/packs/src/decidim/index.js +4 -3
  48. data/app/packs/src/decidim/sw/push-permissions.js +48 -13
  49. data/app/packs/src/decidim/sw/sw.js +1 -1
  50. data/app/packs/src/decidim/utilities/text.js +6 -6
  51. data/app/packs/stylesheets/decidim/_conversations.scss +14 -0
  52. data/app/packs/stylesheets/decidim/_editor_suggestions.scss +49 -0
  53. data/app/packs/stylesheets/decidim/_floating_help.scss +1 -1
  54. data/app/packs/stylesheets/decidim/_tom_select.scss +23 -0
  55. data/app/packs/stylesheets/decidim/application.scss +2 -0
  56. data/app/packs/stylesheets/decidim/editor.scss +2 -33
  57. data/app/packs/stylesheets/decidim/geocoding_addons.scss +10 -2
  58. data/app/presenters/decidim/menu_item_presenter.rb +9 -3
  59. data/app/presenters/decidim/stats_presenter.rb +1 -1
  60. data/app/services/decidim/notifications_subscriptions_persistor.rb +6 -0
  61. data/app/services/decidim/push_subscription_endpoint_validator.rb +34 -0
  62. data/app/services/decidim/send_push_notification.rb +5 -1
  63. data/app/uploaders/decidim/image_uploader.rb +1 -1
  64. data/app/views/decidim/delete_user_mailer/delete.html.erb +6 -0
  65. data/app/views/decidim/gamification/badges/index.html.erb +1 -1
  66. data/app/views/decidim/homepage/show.html.erb +1 -1
  67. data/app/views/decidim/last_activities/index.html.erb +1 -1
  68. data/app/views/decidim/messaging/conversations/_error_modal.html.erb +11 -19
  69. data/app/views/decidim/messaging/conversations/error.js.erb +12 -7
  70. data/app/views/decidim/newsletters/unsubscribe.html.erb +1 -1
  71. data/app/views/decidim/notifications_settings/show.html.erb +5 -5
  72. data/app/views/decidim/offline/show.html.erb +1 -1
  73. data/app/views/decidim/pages/index.html.erb +1 -1
  74. data/app/views/decidim/profiles/show.html.erb +1 -1
  75. data/app/views/decidim/shared/_resource_actions.html.erb +4 -4
  76. data/app/views/decidim/user_activities/index.html.erb +1 -1
  77. data/app/views/layouts/decidim/_wrapper.html.erb +2 -2
  78. data/app/views/layouts/decidim/header/_menu_breadcrumb_mobile_tablet.html.erb +1 -1
  79. data/app/views/layouts/decidim/shared/_layout_center.html.erb +1 -1
  80. data/app/views/layouts/decidim/shared/_layout_item.html.erb +1 -1
  81. data/app/views/layouts/decidim/shared/_layout_two_col.html.erb +1 -1
  82. data/config/locales/ar.yml +0 -5
  83. data/config/locales/bg.yml +0 -6
  84. data/config/locales/ca-IT.yml +10 -10
  85. data/config/locales/ca.yml +10 -10
  86. data/config/locales/cs.yml +4 -13
  87. data/config/locales/de.yml +5 -17
  88. data/config/locales/el.yml +0 -4
  89. data/config/locales/en.yml +9 -9
  90. data/config/locales/es-MX.yml +9 -9
  91. data/config/locales/es-PY.yml +9 -9
  92. data/config/locales/es.yml +9 -9
  93. data/config/locales/eu.yml +49 -49
  94. data/config/locales/fi-plain.yml +9 -9
  95. data/config/locales/fi.yml +11 -11
  96. data/config/locales/fr-CA.yml +10 -9
  97. data/config/locales/fr.yml +10 -9
  98. data/config/locales/gl.yml +0 -3
  99. data/config/locales/hu.yml +0 -5
  100. data/config/locales/id-ID.yml +0 -3
  101. data/config/locales/it.yml +11 -3
  102. data/config/locales/ja.yml +24 -21
  103. data/config/locales/lb.yml +0 -5
  104. data/config/locales/lt.yml +0 -5
  105. data/config/locales/lv.yml +0 -3
  106. data/config/locales/nl.yml +0 -5
  107. data/config/locales/no.yml +0 -5
  108. data/config/locales/pl.yml +2 -8
  109. data/config/locales/pt-BR.yml +3 -16
  110. data/config/locales/pt.yml +0 -5
  111. data/config/locales/ro-RO.yml +1 -14
  112. data/config/locales/sk.yml +1408 -4
  113. data/config/locales/sv.yml +3 -11
  114. data/config/locales/tr-TR.yml +0 -6
  115. data/config/locales/zh-CN.yml +0 -5
  116. data/config/locales/zh-TW.yml +0 -5
  117. data/config/routes.rb +1 -0
  118. data/lib/decidim/api/functions/user_entity_list.rb +2 -0
  119. data/lib/decidim/attachment_attributes.rb +58 -9
  120. data/lib/decidim/command.rb +1 -1
  121. data/lib/decidim/content_renderers/base_renderer.rb +112 -0
  122. data/lib/decidim/content_renderers/blob_renderer.rb +4 -7
  123. data/lib/decidim/content_renderers/mention_resource_renderer.rb +10 -6
  124. data/lib/decidim/content_renderers/resource_renderer.rb +16 -7
  125. data/lib/decidim/content_renderers/user_renderer.rb +11 -9
  126. data/lib/decidim/core/content_blocks/registry_manager.rb +4 -4
  127. data/lib/decidim/core/engine.rb +8 -0
  128. data/lib/decidim/core/test/factories.rb +3 -0
  129. data/lib/decidim/core/test/shared_examples/admin_resource_gallery_examples.rb +10 -10
  130. data/lib/decidim/core/test/shared_examples/comments_examples.rb +6 -6
  131. data/lib/decidim/core/version.rb +1 -1
  132. data/lib/decidim/map/autocomplete.rb +4 -3
  133. data/lib/decidim/searchable.rb +5 -0
  134. data/lib/decidim/view_model.rb +1 -1
  135. data/lib/tasks/decidim_mailers_tasks.rake +31 -9
  136. metadata +14 -10
  137. data/app/commands/decidim/gallery_methods.rb +0 -107
  138. data/app/packs/src/decidim/vendor/tribute.js +0 -1890
  139. data/app/packs/stylesheets/decidim/_tribute.scss +0 -36
  140. data/app/views/decidim/participants_account_mailer/removal_notification.html.erb +0 -11
@@ -69,12 +69,27 @@ module Decidim
69
69
  Decidim::UserBaseEntity.joins(:follows).where(decidim_follows: { user: self }).blocked.exists?
70
70
  end
71
71
 
72
+ ransacker :role do
73
+ Arel.sql(%{CASE WHEN "decidim_users"."admin" = true THEN 'admin' ELSE cast("decidim_users"."roles" as text) END})
74
+ end
75
+
76
+ ransacker :user_moderation_report_count do
77
+ query = <<~SQL.squish
78
+ (
79
+ SELECT COALESCE(MAX(decidim_user_moderations.report_count), 0)
80
+ FROM decidim_user_moderations
81
+ WHERE decidim_user_moderations.decidim_user_id = decidim_users.id
82
+ )
83
+ SQL
84
+ Arel.sql(query)
85
+ end
86
+
72
87
  def self.ransackable_attributes(auth_object = nil)
73
- base = %w(name email nickname last_sign_in_at)
88
+ base = %w(name email nickname last_sign_in_at created_at)
74
89
 
75
90
  return base unless auth_object&.admin?
76
91
 
77
- base + %w(invitation_sent_at invitation_accepted_at officialized_at)
92
+ base + %w(invitation_sent_at invitation_accepted_at officialized_at role user_moderation_report_count)
78
93
  end
79
94
 
80
95
  def self.ransackable_associations(_auth_object = nil)
@@ -19,7 +19,7 @@ module Decidim
19
19
  end
20
20
 
21
21
  def self.ransackable_attributes(_auth_object = nil)
22
- []
22
+ %w(created_at report_count)
23
23
  end
24
24
 
25
25
  def self.ransackable_associations(_auth_object = nil)
@@ -419,13 +419,13 @@ class FormValidator {
419
419
  return true;
420
420
  }
421
421
 
422
- checkboxGroup.forEach((checkboxElement) => {
423
- if (isValid) {
424
- this.removeInputErrorClasses(checkboxElement);
425
- } else {
422
+ if (isValid) {
423
+ this.removeCheckboxGroupErrorClasses(groupName);
424
+ } else {
425
+ checkboxGroup.forEach((checkboxElement) => {
426
426
  this.addInputErrorClasses(checkboxElement, ["required"]);
427
- }
428
- });
427
+ });
428
+ }
429
429
 
430
430
  return isValid;
431
431
  }
@@ -1,4 +1,4 @@
1
- /* eslint max-lines: ["error", 690] */
1
+ /* eslint max-lines: ["error", 712] */
2
2
  /* global jest */
3
3
  /**
4
4
  * @jest-environment jsdom
@@ -253,6 +253,28 @@ describe("FormValidator", () => {
253
253
  const isValidTwo = validatorInstance.validateCheckboxGroup("multiCheckboxes");
254
254
  expect(isValidTwo).toBe(true);
255
255
  });
256
+
257
+ it("should remove error classes once for the group, not per checkbox", () => {
258
+ formElement.innerHTML = `
259
+ <fieldset>
260
+ <input type="checkbox" id="cb-1" name="largeGroup" value="1" required>
261
+ <input type="checkbox" id="cb-2" name="largeGroup" value="2" required>
262
+ <input type="checkbox" id="cb-3" name="largeGroup" value="3" required>
263
+ </fieldset>
264
+ <button type="submit">Submit</button>
265
+ `;
266
+ validatorInstance = new FormValidator(formElement);
267
+ formElement.querySelector("#cb-1").checked = true;
268
+
269
+ const removeGroupSpy = jest.spyOn(validatorInstance, "removeCheckboxGroupErrorClasses");
270
+ const removeInputSpy = jest.spyOn(validatorInstance, "removeInputErrorClasses");
271
+
272
+ const isValid = validatorInstance.validateCheckboxGroup("largeGroup");
273
+
274
+ expect(isValid).toBe(true);
275
+ expect(removeGroupSpy).toHaveBeenCalledTimes(1);
276
+ expect(removeInputSpy).not.toHaveBeenCalled();
277
+ });
256
278
  });
257
279
 
258
280
  describe("Custom Validators", () => {
@@ -1,5 +1,6 @@
1
+ /* eslint max-lines: ["error", 400] */
2
+
1
3
  import { Controller } from "@hotwired/stimulus"
2
- import Tribute from "src/decidim/vendor/tribute";
3
4
 
4
5
  export default class extends Controller {
5
6
  connect() {
@@ -9,7 +10,12 @@ export default class extends Controller {
9
10
  menuItemLimit: 5
10
11
  };
11
12
 
12
- this.tribute = null;
13
+ this.suggestion = null;
14
+ this.suggestions = [];
15
+ this.selectedIndex = -1;
16
+ this.isActive = false;
17
+ this.currentMentionStart = null;
18
+ this.requestId = 0;
13
19
  this.isInitialized = false;
14
20
 
15
21
  // Prevent initialization inside editor components
@@ -17,100 +23,62 @@ export default class extends Controller {
17
23
  return;
18
24
  }
19
25
 
20
- if (this.element.hasAttribute("data-tribute")) {
21
- this.element.removeAttribute("data-tribute");
22
- }
23
-
24
- this.createTribute();
26
+ this.createSuggestionContainer();
25
27
  this.setupEventListeners();
26
28
  this.isInitialized = true;
27
29
  }
28
30
 
29
31
  disconnect() {
30
- if (this.tribute) {
31
- this.tribute.detach(this.element);
32
+ if (this.suggestion) {
33
+ this.suggestion.remove();
32
34
  }
33
35
 
34
36
  this.element.removeEventListener("focusin", this.handleFocusIn);
35
37
  this.element.removeEventListener("focusout", this.handleFocusOut);
36
38
  this.element.removeEventListener("input", this.handleInput);
39
+ this.element.removeEventListener("keydown", this.handleKeyDown);
40
+ document.removeEventListener("click", this.handleDocumentClick);
37
41
 
38
- this.tribute = null;
42
+ this.suggestion = null;
43
+ this.suggestions = [];
39
44
  this.isInitialized = false;
40
45
  }
41
46
 
42
- /**
43
- * Create and configure the Tribute instance
44
- * @returns {void}
45
- * @private
46
- */
47
- createTribute() {
48
- const noMatchTemplate = this.options.noDataFoundMessage
49
- ? () => `<li>${this.options.noDataFoundMessage}</li>`
50
- : null;
51
-
52
- this.tribute = new Tribute({
53
- trigger: "@",
54
- values: this.debounce((text, callback) => {
55
- this.performRemoteSearch(text, callback);
56
- }, this.options.debounceDelay),
57
- positionMenu: true,
58
- menuContainer: null,
59
- allowSpaces: true,
60
- menuItemLimit: this.options.menuItemLimit,
61
- fillAttr: "nickname",
62
- selectClass: "highlight",
63
- noMatchTemplate: noMatchTemplate,
64
- lookup: (item) => item.nickname + item.name,
65
- selectTemplate: (item) => {
66
- if (typeof item === "undefined") {
67
- return null;
68
- }
69
- return item.original.nickname;
70
- },
71
- menuItemTemplate: (item) => {
72
- return `
73
- <img src="${item.original.avatarUrl}" alt="author-avatar">
74
- <strong>${item.original.nickname}</strong>
75
- <small>${item.original.name}</small>
76
- `;
77
- }
78
- });
47
+ createSuggestionContainer() {
48
+ this.suggestion = document.createElement("div");
49
+ this.suggestion.classList.add("editor-suggestions", "hidden", "hide");
50
+ document.body.append(this.suggestion);
51
+ this.suggestion.addEventListener("mousedown", (event) => event.preventDefault());
79
52
 
80
- this.tribute.attach(this.element);
53
+ this.performRemoteSearch = this.debounce(this.performRemoteSearch.bind(this), this.options.debounceDelay);
81
54
  }
82
55
 
83
- /**
84
- * Set up event listeners for the element
85
- * @returns {void}
86
- * @private
87
- */
88
56
  setupEventListeners() {
89
- // Handle focus events to set menu container
90
- this.element.addEventListener("focusin", this.handleFocusIn.bind(this));
91
- this.element.addEventListener("focusout", this.handleFocusOut.bind(this));
92
- this.element.addEventListener("input", this.handleInput.bind(this));
93
- }
94
-
95
- /**
96
- * Handle focus in event
97
- * @param {Event} event - The focus in event
98
- * @returns {void}
99
- * @private
100
- */
101
- handleFocusIn(event) {
102
- if (this.tribute) {
103
- this.tribute.menuContainer = event.target.parentNode;
104
- }
105
- }
106
-
107
- /**
108
- * Handle focus out event
109
- * @param {Event} event - The focus out event
110
- * @returns {void}
111
- * @private
112
- */
57
+ this.handleFocusIn = this.handleFocusIn.bind(this);
58
+ this.handleFocusOut = this.handleFocusOut.bind(this);
59
+ this.handleInput = this.handleInput.bind(this);
60
+ this.handleKeyDown = this.handleKeyDown.bind(this);
61
+ this.handleDocumentClick = this.handleDocumentClick.bind(this);
62
+
63
+ this.element.addEventListener("focusin", this.handleFocusIn);
64
+ this.element.addEventListener("focusout", this.handleFocusOut);
65
+ this.element.addEventListener("input", this.handleInput);
66
+ this.element.addEventListener("keydown", this.handleKeyDown);
67
+ document.addEventListener("click", this.handleDocumentClick);
68
+ }
69
+
70
+ handleFocusIn() {
71
+ if (this.element.parentNode) {
72
+ this.element.parentNode.classList.add("is-active");
73
+ }
74
+ }
75
+
113
76
  handleFocusOut(event) {
77
+ if (this.suggestion && this.suggestion.contains(event.relatedTarget)) {
78
+ return;
79
+ }
80
+
81
+ this.hideSuggestions();
114
82
  const parent = event.target.parentNode;
115
83
 
116
84
  if (parent && parent.classList.contains("is-active")) {
@@ -118,63 +86,99 @@ export default class extends Controller {
118
86
  }
119
87
  }
120
88
 
121
- /**
122
- * Handle input event
123
- * @param {Event} event - The input event
124
- * @returns {void}
125
- * @private
126
- */
127
- handleInput(event) {
128
- const parent = event.target.parentNode;
89
+ handleInput() {
90
+ const trigger = this.mentionTriggerAtCursor();
91
+ if (!trigger) {
92
+ this.hideSuggestions();
93
+ return;
94
+ }
95
+
96
+ this.currentMentionStart = trigger.start;
97
+ this.performRemoteSearch(trigger.query);
98
+ }
129
99
 
130
- if (!parent) {
100
+ handleKeyDown(event) {
101
+ if (!this.isActive) {
131
102
  return;
132
103
  }
133
104
 
134
- if (this.tribute && this.tribute.isActive) {
135
- // Move the tribute container to the correct parent
136
- const tributeContainer = document.querySelector(".tribute-container");
137
- if (tributeContainer) {
138
- parent.appendChild(tributeContainer);
139
- }
105
+ if (event.key === "Escape") {
106
+ event.preventDefault();
107
+ this.hideSuggestions();
108
+ } else if (event.key === "ArrowDown") {
109
+ event.preventDefault();
110
+ this.updateSelectedIndex(1);
111
+ this.renderSuggestions();
112
+ } else if (event.key === "ArrowUp") {
113
+ event.preventDefault();
114
+ this.updateSelectedIndex(-1);
115
+ this.renderSuggestions();
116
+ } else if (event.key === "Enter") {
117
+ event.preventDefault();
118
+ this.selectSuggestion(this.selectedIndex);
119
+ }
120
+ }
140
121
 
141
- parent.classList.add("is-active");
142
- } else {
143
- parent.classList.remove("is-active");
122
+ handleDocumentClick(event) {
123
+ if (this.element === event.target || this.element.contains(event.target) || this.suggestion?.contains(event.target)) {
124
+ return;
125
+ }
126
+
127
+ this.hideSuggestions();
128
+ }
129
+
130
+ mentionTriggerAtCursor() {
131
+ const value = this.element.value || "";
132
+ const caretPosition = this.element.selectionStart;
133
+
134
+ if (typeof caretPosition !== "number") {
135
+ return null;
136
+ }
137
+
138
+ const textBeforeCursor = value.slice(0, caretPosition);
139
+ const mentionMatch = textBeforeCursor.match(/(?:^|\s)@([\w.-]{2,})$/);
140
+ if (!mentionMatch) {
141
+ return null;
144
142
  }
143
+
144
+ return {
145
+ query: mentionMatch[1],
146
+ start: caretPosition - mentionMatch[1].length - 1
147
+ };
145
148
  }
146
149
 
147
- /**
148
- * Perform remote search for users
149
- * @param {string} text - The search text
150
- * @param {Function} callback - The callback function to call with results
151
- * @returns {void}
152
- * @private
153
- */
154
- performRemoteSearch(text, callback) {
150
+ performRemoteSearch(text) {
151
+ const currentRequestId = this.requestId + 1;
152
+ this.requestId = currentRequestId;
153
+
155
154
  const query = `{users(filter:{wildcard:"${text}"}){nickname,name,avatarUrl,__typename}}`;
156
155
  const apiPath = window.Decidim.config.get("api_path");
157
156
 
158
157
  this.makeRequest(apiPath, { query }).
159
158
  then((response) => {
159
+ if (this.requestId !== currentRequestId) {
160
+ return;
161
+ }
162
+
160
163
  const data = response.data.users || [];
161
- callback(data);
164
+ const sortedData = data.sort((first, second) => first.nickname.localeCompare(second.nickname));
165
+ this.suggestions = sortedData.slice(0, this.options.menuItemLimit);
166
+ this.selectedIndex = this.suggestions.length > 0
167
+ ? 0
168
+ : -1;
169
+ this.renderSuggestions({ showNoResults: true });
162
170
  }).
163
171
  catch(() => {
164
- callback([]);
165
- }).
166
- finally(() => {
167
- this.adjustTributeContainer();
172
+ if (this.requestId !== currentRequestId) {
173
+ return;
174
+ }
175
+
176
+ this.suggestions = [];
177
+ this.selectedIndex = -1;
178
+ this.renderSuggestions({ showNoResults: true });
168
179
  });
169
180
  }
170
181
 
171
- /**
172
- * Make an HTTP POST request
173
- * @param {string} url - The request URL
174
- * @param {Object} data - The request data
175
- * @returns {Promise} The request promise
176
- * @private
177
- */
178
182
  makeRequest(url, data) {
179
183
  return fetch(url, {
180
184
  method: "POST",
@@ -191,35 +195,191 @@ export default class extends Controller {
191
195
  });
192
196
  }
193
197
 
194
- /**
195
- * Adjust the tribute container positioning and styling
196
- * @returns {void}
197
- * @private
198
- */
199
- adjustTributeContainer() {
200
- if (!this.tribute || !this.tribute.current || !this.tribute.current.element) {
198
+ renderSuggestions({ showNoResults = false } = {}) {
199
+ if (!this.suggestion) {
200
+ return;
201
+ }
202
+
203
+ this.suggestion.innerHTML = "";
204
+
205
+ if (this.suggestions.length < 1) {
206
+ this.isActive = false;
207
+
208
+ if (showNoResults && this.options.noDataFoundMessage) {
209
+ const noResultsItem = document.createElement("button");
210
+ noResultsItem.type = "button";
211
+ noResultsItem.disabled = true;
212
+ noResultsItem.classList.add("editor-suggestions-item", "editor-suggestions-item-disabled");
213
+ noResultsItem.textContent = this.options.noDataFoundMessage;
214
+ this.suggestion.append(noResultsItem);
215
+ this.positionSuggestionMenu();
216
+ this.suggestion.classList.remove("hidden", "hide");
217
+ } else {
218
+ this.suggestion.classList.add("hidden", "hide");
219
+ }
220
+
201
221
  return;
202
222
  }
203
223
 
204
- const parent = this.tribute.current.element.parentNode;
205
- if (parent) {
206
- parent.classList.add("is-active");
224
+ this.suggestions.forEach((item, index) => {
225
+ const suggestionItem = document.createElement("button");
226
+ suggestionItem.type = "button";
227
+ suggestionItem.classList.add("editor-suggestions-item");
228
+ suggestionItem.dataset.index = index;
229
+
230
+ if (item.avatarUrl) {
231
+ const avatar = document.createElement("img");
232
+ avatar.classList.add("editor-suggestions-item-avatar");
233
+ avatar.src = item.avatarUrl;
234
+ avatar.alt = item.name || item.nickname;
235
+ suggestionItem.append(avatar);
236
+ }
237
+
238
+ const label = document.createElement("span");
239
+ label.classList.add("editor-suggestions-item-label");
240
+ label.textContent = `${item.nickname} (${item.name})`;
241
+ suggestionItem.append(label);
207
242
 
208
- const tributeContainer = parent.querySelector(".tribute-container");
209
- if (tributeContainer) {
210
- // Remove inline styles for absolute positioning
211
- tributeContainer.removeAttribute("style");
243
+ if (index === this.selectedIndex) {
244
+ suggestionItem.dataset.selected = "true";
212
245
  }
246
+
247
+ suggestionItem.addEventListener("click", (event) => {
248
+ event.preventDefault();
249
+ this.selectSuggestion(index);
250
+ });
251
+
252
+ this.suggestion.append(suggestionItem);
253
+ });
254
+
255
+ this.positionSuggestionMenu();
256
+ this.isActive = true;
257
+ this.suggestion.classList.remove("hidden", "hide");
258
+ }
259
+
260
+ positionSuggestionMenu() {
261
+ if (!this.suggestion || this.currentMentionStart === null) {
262
+ return;
213
263
  }
264
+
265
+ const coordinates = this.coordinatesForTextIndex(this.currentMentionStart);
266
+ if (!coordinates) {
267
+ return;
268
+ }
269
+
270
+ Object.assign(this.suggestion.style, {
271
+ position: "absolute",
272
+ top: `${coordinates.top}px`,
273
+ left: `${coordinates.left}px`
274
+ });
275
+ }
276
+
277
+ coordinatesForTextIndex(index) {
278
+ const element = this.element;
279
+ const styles = window.getComputedStyle(element);
280
+ const elementRect = element.getBoundingClientRect();
281
+ const borderTopWidth = parseFloat(styles.borderTopWidth) || 0;
282
+ const borderLeftWidth = parseFloat(styles.borderLeftWidth) || 0;
283
+ const lineHeight = parseFloat(styles.lineHeight) || 16;
284
+
285
+ const mirror = document.createElement("div");
286
+ const mirrorStyles = [
287
+ "fontFamily",
288
+ "fontSize",
289
+ "fontWeight",
290
+ "fontStyle",
291
+ "letterSpacing",
292
+ "textTransform",
293
+ "wordSpacing",
294
+ "textIndent",
295
+ "boxSizing",
296
+ "width",
297
+ "paddingTop",
298
+ "paddingRight",
299
+ "paddingBottom",
300
+ "paddingLeft",
301
+ "borderTopWidth",
302
+ "borderRightWidth",
303
+ "borderBottomWidth",
304
+ "borderLeftWidth",
305
+ "borderStyle",
306
+ "lineHeight"
307
+ ];
308
+
309
+ mirrorStyles.forEach((property) => {
310
+ mirror.style[property] = styles[property];
311
+ });
312
+
313
+ mirror.style.position = "absolute";
314
+ mirror.style.visibility = "hidden";
315
+ mirror.style.whiteSpace = element.nodeName === "TEXTAREA"
316
+ ? "pre-wrap"
317
+ : "pre";
318
+ mirror.style.overflowWrap = "break-word";
319
+ mirror.style.top = "0";
320
+ mirror.style.left = "-9999px";
321
+
322
+ const before = document.createTextNode((element.value || "").slice(0, index));
323
+ const marker = document.createElement("span");
324
+ marker.textContent = "@";
325
+
326
+ mirror.append(before);
327
+ mirror.append(marker);
328
+ document.body.append(mirror);
329
+
330
+ const top = elementRect.top + window.scrollY + marker.offsetTop - element.scrollTop + borderTopWidth + lineHeight;
331
+ const left = elementRect.left + window.scrollX + marker.offsetLeft - element.scrollLeft + borderLeftWidth;
332
+
333
+ mirror.remove();
334
+
335
+ return { top, left };
336
+ }
337
+
338
+ updateSelectedIndex(direction) {
339
+ if (this.suggestions.length < 1) {
340
+ this.selectedIndex = -1;
341
+ return;
342
+ }
343
+
344
+ const maxIndex = this.suggestions.length - 1;
345
+ const nextIndex = this.selectedIndex + direction;
346
+
347
+ this.selectedIndex = Math.max(0, Math.min(nextIndex, maxIndex));
348
+ }
349
+
350
+ selectSuggestion(index) {
351
+ const selectedItem = this.suggestions[index];
352
+ if (!selectedItem || this.currentMentionStart === null) {
353
+ return;
354
+ }
355
+
356
+ const cursorPosition = this.element.selectionStart;
357
+ const value = this.element.value || "";
358
+ const mentionValue = `${selectedItem.nickname} `;
359
+ const newValue = `${value.slice(0, this.currentMentionStart)}${mentionValue}${value.slice(cursorPosition)}`;
360
+
361
+ this.element.value = newValue;
362
+
363
+ const newPosition = this.currentMentionStart + mentionValue.length;
364
+ this.element.setSelectionRange(newPosition, newPosition);
365
+
366
+ this.element.dispatchEvent(new Event("input", { bubbles: true }));
367
+ this.hideSuggestions();
368
+ }
369
+
370
+ hideSuggestions() {
371
+ if (!this.suggestion) {
372
+ return;
373
+ }
374
+
375
+ this.isActive = false;
376
+ this.currentMentionStart = null;
377
+ this.selectedIndex = -1;
378
+ this.suggestions = [];
379
+ this.suggestion.classList.add("hidden", "hide");
380
+ this.suggestion.innerHTML = "";
214
381
  }
215
382
 
216
- /**
217
- * Create a debounced version of a function
218
- * @param {Function} callback - The function to debounce
219
- * @param {number} wait - The debounce delay in milliseconds
220
- * @returns {Function} The debounced function
221
- * @private
222
- */
223
383
  debounce(callback, wait) {
224
384
  let timeout = null;
225
385
  return (...args) => {
@@ -233,10 +393,6 @@ export default class extends Controller {
233
393
  };
234
394
  }
235
395
 
236
- /**
237
- * Check if the component is initialized
238
- * @returns {boolean} True if initialized, false otherwise
239
- */
240
396
  get initialized() {
241
397
  return this.isInitialized;
242
398
  }