decidim-core 0.31.2 → 0.31.4

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 (101) hide show
  1. checksums.yaml +4 -4
  2. data/app/cells/decidim/content_blocks/highlighted_elements_with_cell_for_list_cell.rb +5 -1
  3. data/app/cells/decidim/nav_links/show.erb +3 -3
  4. data/app/cells/decidim/participatory_space_private_user/show.erb +6 -6
  5. data/app/cells/decidim/participatory_space_private_user_cell.rb +0 -4
  6. data/app/cells/decidim/report_button/already_reported_modal.erb +1 -1
  7. data/app/cells/decidim/report_button/flag_modal.erb +1 -1
  8. data/app/cells/decidim/report_user_button/already_reported_modal.erb +1 -1
  9. data/app/cells/decidim/report_user_button/flag_modal.erb +1 -1
  10. data/app/cells/decidim/share_text_widget/modal.erb +1 -1
  11. data/app/cells/decidim/upload_modal/files.erb +5 -1
  12. data/app/cells/decidim/upload_modal_cell.rb +10 -1
  13. data/app/commands/decidim/multiple_attachments_methods.rb +20 -3
  14. data/app/helpers/decidim/mailer_helper.rb +36 -0
  15. data/app/helpers/decidim/menu_helper.rb +2 -1
  16. data/app/helpers/decidim/newsletters_helper.rb +4 -22
  17. data/app/jobs/decidim/find_and_update_descendants_job.rb +8 -2
  18. data/app/jobs/decidim/update_search_indexes_job.rb +2 -2
  19. data/app/mailers/decidim/application_mailer.rb +4 -0
  20. data/app/packs/src/decidim/a11y.js +29 -0
  21. data/app/packs/src/decidim/a11y.test.js +81 -0
  22. data/app/packs/src/decidim/confirm.js +8 -1
  23. data/app/packs/src/decidim/confirm.test.js +225 -0
  24. data/app/packs/src/decidim/controllers/accordion/accordion.test.js +118 -0
  25. data/app/packs/src/decidim/controllers/accordion/controller.js +24 -0
  26. data/app/packs/src/decidim/controllers/dropdown/controller.js +26 -0
  27. data/app/packs/src/decidim/controllers/dropdown/dropdown.test.js +187 -0
  28. data/app/packs/src/decidim/controllers/form_validator/form_validator.js +3 -2
  29. data/app/packs/src/decidim/controllers/form_validator/form_validator.test.js +5 -0
  30. data/app/packs/src/decidim/controllers/language_change/controller.js +1 -0
  31. data/app/packs/src/decidim/controllers/language_change/language_change.test.js +13 -0
  32. data/app/packs/src/decidim/datepicker/datepicker_functions.js +26 -0
  33. data/app/packs/src/decidim/datepicker/generate_datepicker.js +2 -1
  34. data/app/packs/src/decidim/datepicker/generate_timepicker.js +3 -2
  35. data/app/packs/src/decidim/datepicker/test/datepicker_functions_adjust_picker_position.test.js +234 -0
  36. data/app/packs/src/decidim/editor/extensions/image/index.js +49 -11
  37. data/app/packs/src/decidim/editor/extensions/image/node_view.js +9 -1
  38. data/app/packs/src/decidim/editor/extensions/link/bubble_menu.js +34 -6
  39. data/app/packs/src/decidim/editor/extensions/link/index.js +45 -12
  40. data/app/packs/src/decidim/editor/test/extensions/image_links.test.js +161 -0
  41. data/app/packs/src/decidim/refactor/moved/focus_guard.js +4 -4
  42. data/app/packs/stylesheets/decidim/_cards.scss +12 -4
  43. data/app/packs/stylesheets/decidim/_flash.scss +1 -1
  44. data/app/packs/stylesheets/decidim/_rich_text.scss +17 -0
  45. data/app/packs/stylesheets/decidim/editor.scss +10 -0
  46. data/app/presenters/decidim/menu_item_presenter.rb +7 -1
  47. data/app/views/decidim/devise/invitations/edit.html.erb +3 -3
  48. data/app/views/decidim/devise/registrations/new.html.erb +1 -0
  49. data/app/views/decidim/devise/shared/_tos_fields.html.erb +3 -3
  50. data/app/views/decidim/notification_mailer/event_received.html.erb +3 -3
  51. data/app/views/decidim/pages/_tabbed.html.erb +3 -3
  52. data/app/views/decidim/shared/_filters.html.erb +5 -5
  53. data/app/views/decidim/shared/filters/_check_boxes_tree.html.erb +1 -1
  54. data/app/views/decidim/shared/filters/_collection.html.erb +1 -1
  55. data/config/initializers/devise.rb +6 -0
  56. data/config/locales/ar.yml +3 -3
  57. data/config/locales/bg.yml +0 -4
  58. data/config/locales/ca-IT.yml +7 -6
  59. data/config/locales/ca.yml +7 -6
  60. data/config/locales/cs.yml +5 -8
  61. data/config/locales/de.yml +31 -8
  62. data/config/locales/el.yml +0 -2
  63. data/config/locales/en.yml +5 -4
  64. data/config/locales/es-MX.yml +10 -9
  65. data/config/locales/es-PY.yml +10 -9
  66. data/config/locales/es.yml +12 -11
  67. data/config/locales/eu.yml +7 -5
  68. data/config/locales/fi-plain.yml +10 -4
  69. data/config/locales/fi.yml +11 -5
  70. data/config/locales/fr-CA.yml +7 -5
  71. data/config/locales/fr.yml +8 -7
  72. data/config/locales/gl.yml +0 -2
  73. data/config/locales/hu.yml +4 -8
  74. data/config/locales/id-ID.yml +0 -2
  75. data/config/locales/it.yml +1 -3
  76. data/config/locales/ja.yml +7 -8
  77. data/config/locales/lb.yml +0 -2
  78. data/config/locales/lt.yml +1 -3
  79. data/config/locales/lv.yml +0 -2
  80. data/config/locales/nl.yml +0 -2
  81. data/config/locales/no.yml +0 -2
  82. data/config/locales/pl.yml +0 -4
  83. data/config/locales/pt-BR.yml +4 -5
  84. data/config/locales/pt.yml +0 -2
  85. data/config/locales/ro-RO.yml +1 -5
  86. data/config/locales/ru.yml +0 -2
  87. data/config/locales/sk.yml +0 -4
  88. data/config/locales/sv.yml +8 -7
  89. data/config/locales/tr-TR.yml +17 -5
  90. data/config/locales/zh-CN.yml +0 -2
  91. data/config/locales/zh-TW.yml +1 -3
  92. data/lib/decidim/assets/tailwind/tailwind.config.js.erb +1 -1
  93. data/lib/decidim/content_parsers/blob_parser.rb +3 -3
  94. data/lib/decidim/content_renderers/blob_renderer.rb +2 -2
  95. data/lib/decidim/core/test/shared_examples/participatory_space_members_shared_examples.rb +121 -0
  96. data/lib/decidim/core/version.rb +1 -1
  97. data/lib/decidim/form_builder.rb +58 -36
  98. data/lib/decidim/maintenance/taxonomy_importer.rb +1 -1
  99. data/lib/decidim/participatory_space_user.rb +1 -1
  100. data/lib/decidim/searchable.rb +4 -4
  101. metadata +14 -6
@@ -1,5 +1,31 @@
1
1
  // Utility helper functions for the date and time picker functionality
2
2
 
3
+ export const adjustPickerPosition = (input, datePickerContainer, selector) => {
4
+ const parent = input.closest(selector);
5
+
6
+ if (getComputedStyle(parent).position === "static") {
7
+ parent.style.position = "relative";
8
+ }
9
+
10
+ const rect = input.getBoundingClientRect();
11
+ const calendarHeight = datePickerContainer.offsetHeight;
12
+ const spaceAbove = rect.top;
13
+ const spaceBelow = window.innerHeight - rect.bottom;
14
+ const openBelow = spaceBelow >= calendarHeight || spaceBelow >= spaceAbove;
15
+
16
+ if (openBelow) {
17
+ // Open below
18
+ datePickerContainer.style.top = `${input.offsetHeight}px`;
19
+ datePickerContainer.style.bottom = "";
20
+ } else {
21
+ // Open above
22
+ datePickerContainer.style.top = "";
23
+ datePickerContainer.style.bottom = `${input.offsetHeight}px`;
24
+ }
25
+
26
+ datePickerContainer.style.right = "0px";
27
+ };
28
+
3
29
  export const setHour = (value, format) => {
4
30
  const hour = value.split(":")[0];
5
31
  if (format === 12) {
@@ -1,6 +1,6 @@
1
1
  /* eslint-disable require-jsdoc */
2
2
  import icon from "src/decidim/refactor/moved/icon"
3
- import { dateToPicker, formatDate, displayDate, formatTime, calculateDatepickerPos } from "src/decidim/datepicker/datepicker_functions"
3
+ import { dateToPicker, formatDate, displayDate, formatTime, calculateDatepickerPos, adjustPickerPosition } from "src/decidim/datepicker/datepicker_functions"
4
4
  import { dateKeyDownListener, dateBeforeInputListener } from "src/decidim/datepicker/datepicker_listeners"
5
5
  import { getDictionary } from "src/decidim/refactor/moved/i18n"
6
6
 
@@ -136,6 +136,7 @@ export default function generateDatePicker(input, row, formats) {
136
136
  };
137
137
  pickedDate = null;
138
138
  datePickerContainer.style.display = "block";
139
+ adjustPickerPosition(date, datePickerContainer, ".datepicker__date-column");
139
140
 
140
141
  document.addEventListener("click", datePickerDisplay);
141
142
 
@@ -2,7 +2,7 @@
2
2
  /* eslint max-lines: ["error", 310] */
3
3
 
4
4
  import icon from "src/decidim/refactor/moved/icon"
5
- import { changeHourDisplay, changeMinuteDisplay, formatDate, hourDisplay, minuteDisplay, formatTime, setHour, setMinute, updateTimeValue, updateInputValue } from "src/decidim/datepicker/datepicker_functions"
5
+ import { changeHourDisplay, changeMinuteDisplay, formatDate, hourDisplay, minuteDisplay, formatTime, setHour, setMinute, updateTimeValue, updateInputValue, adjustPickerPosition } from "src/decidim/datepicker/datepicker_functions"
6
6
  import { timeKeyDownListener, timeBeforeInputListener } from "src/decidim/datepicker/datepicker_listeners";
7
7
  import { getDictionary } from "src/decidim/refactor/moved/i18n";
8
8
 
@@ -29,7 +29,6 @@ export default function generateTimePicker(input, row, formats) {
29
29
  clock.setAttribute("disabled", input.attributes.disabled);
30
30
  };
31
31
 
32
-
33
32
  timeColumn.appendChild(time);
34
33
  timeColumn.appendChild(clock);
35
34
 
@@ -279,6 +278,8 @@ export default function generateTimePicker(input, row, formats) {
279
278
  event.preventDefault();
280
279
  timePicker.style.display = "block";
281
280
  document.addEventListener("click", timePickerDisplay);
281
+ adjustPickerPosition(time, timePicker, ".datepicker__time-column")
282
+
282
283
  hours.value = hourDisplay(hour);
283
284
  minutes.value = minuteDisplay(minute);
284
285
  });
@@ -0,0 +1,234 @@
1
+ /* global jest */
2
+
3
+ import { adjustPickerPosition } from "src/decidim/datepicker/datepicker_functions";
4
+
5
+ describe("adjustDatePickerPosition", () => {
6
+ let input = null;
7
+ let parent = null;
8
+ let datePickerContainer = null;
9
+
10
+ let originalInnerHeight = window.innerHeight;
11
+
12
+ beforeEach(() => {
13
+ // Setup DOM structure
14
+ parent = document.createElement("div");
15
+ parent.className = "datepicker__date-column";
16
+ document.body.appendChild(parent);
17
+
18
+ input = document.createElement("input");
19
+ Reflect.defineProperty(input, "offsetHeight", {
20
+ configurable: true,
21
+ value: 40
22
+ });
23
+ parent.appendChild(input);
24
+
25
+ datePickerContainer = document.createElement("div");
26
+ datePickerContainer.className = "datepicker__container";
27
+ parent.appendChild(datePickerContainer);
28
+
29
+ // Mock offsetHeight for calendar
30
+ Reflect.defineProperty(datePickerContainer, "offsetHeight", {
31
+ configurable: true,
32
+ value: 300
33
+ });
34
+
35
+ // store original viewport height
36
+ originalInnerHeight = window.innerHeight;
37
+ });
38
+
39
+ afterEach(() => {
40
+ document.body.removeChild(parent);
41
+
42
+ Reflect.defineProperty(window, "innerHeight", {
43
+ writable: true,
44
+ configurable: true,
45
+ value: originalInnerHeight
46
+ });
47
+
48
+ jest.restoreAllMocks();
49
+ });
50
+
51
+ it("sets parent position to relative when static", () => {
52
+ parent.style.position = "static";
53
+
54
+ adjustPickerPosition(input, datePickerContainer, ".datepicker__date-column");
55
+
56
+ expect(parent.style.position).toBe("relative");
57
+ });
58
+
59
+ it("does not change parent position when already positioned", () => {
60
+ parent.style.position = "absolute";
61
+
62
+ adjustPickerPosition(input, datePickerContainer, ".datepicker__date-column");
63
+
64
+ expect(parent.style.position).toBe("absolute");
65
+ });
66
+
67
+ it("opens below when sufficient space below", () => {
68
+ jest.spyOn(input, "getBoundingClientRect").mockReturnValue({
69
+ top: 100,
70
+ bottom: 140
71
+ });
72
+
73
+ Reflect.defineProperty(window, "innerHeight", {
74
+ writable: true,
75
+ configurable: true,
76
+ value: 800
77
+ });
78
+
79
+ adjustPickerPosition(input, datePickerContainer, ".datepicker__date-column");
80
+
81
+ expect(datePickerContainer.style.top).toBe("40px");
82
+ expect(datePickerContainer.style.bottom).toBe("");
83
+ });
84
+
85
+ it("opens above when insufficient space below", () => {
86
+ jest.spyOn(input, "getBoundingClientRect").mockReturnValue({
87
+ top: 400,
88
+ bottom: 440
89
+ });
90
+
91
+ Reflect.defineProperty(window, "innerHeight", {
92
+ writable: true,
93
+ configurable: true,
94
+ value: 500
95
+ });
96
+
97
+ adjustPickerPosition(input, datePickerContainer, ".datepicker__date-column");
98
+
99
+ expect(datePickerContainer.style.top).toBe("");
100
+ expect(datePickerContainer.style.bottom).toBe("40px");
101
+ });
102
+
103
+ it("prefers opening below when space is equal above and below", () => {
104
+ jest.spyOn(input, "getBoundingClientRect").mockReturnValue({
105
+ top: 250,
106
+ bottom: 290
107
+ });
108
+
109
+ Reflect.defineProperty(window, "innerHeight", {
110
+ writable: true,
111
+ configurable: true,
112
+ value: 540
113
+ });
114
+
115
+ adjustPickerPosition(input, datePickerContainer, ".datepicker__date-column");
116
+
117
+ expect(datePickerContainer.style.top).toBe("40px");
118
+ expect(datePickerContainer.style.bottom).toBe("");
119
+ });
120
+
121
+ it("always sets right position to 0px", () => {
122
+ jest.spyOn(input, "getBoundingClientRect").mockReturnValue({
123
+ top: 100,
124
+ bottom: 140
125
+ });
126
+
127
+ adjustPickerPosition(input, datePickerContainer, ".datepicker__date-column");
128
+
129
+ expect(datePickerContainer.style.right).toBe("0px");
130
+ });
131
+ });
132
+
133
+
134
+ describe("adjustTimePickerPosition", () => {
135
+ let input = null;
136
+ let parent = null;
137
+ let timePicker = null;
138
+
139
+ let originalInnerHeight = window.innerHeight;
140
+
141
+ beforeEach(() => {
142
+ parent = document.createElement("div");
143
+ parent.className = "datepicker__time-column";
144
+ document.body.appendChild(parent);
145
+
146
+ input = document.createElement("input");
147
+ Reflect.defineProperty(input, "offsetHeight", {
148
+ configurable: true,
149
+ value: 30
150
+ });
151
+ parent.appendChild(input);
152
+
153
+ timePicker = document.createElement("div");
154
+ timePicker.className = "timepicker__container";
155
+ parent.appendChild(timePicker);
156
+
157
+ Reflect.defineProperty(timePicker, "offsetHeight", {
158
+ configurable: true,
159
+ value: 200
160
+ });
161
+
162
+ // store original value before any test mutates it
163
+ originalInnerHeight = window.innerHeight;
164
+ });
165
+
166
+ afterEach(() => {
167
+ // restore DOM
168
+ document.body.removeChild(parent);
169
+
170
+ // restore window.innerHeight (fix for CodeRabbit warning)
171
+ Reflect.defineProperty(window, "innerHeight", {
172
+ writable: true,
173
+ configurable: true,
174
+ value: originalInnerHeight
175
+ });
176
+
177
+ jest.restoreAllMocks();
178
+ });
179
+
180
+ it("sets parent position to relative when static", () => {
181
+ parent.style.position = "static";
182
+
183
+ adjustPickerPosition(input, timePicker, ".datepicker__time-column");
184
+
185
+ expect(parent.style.position).toBe("relative");
186
+ });
187
+
188
+ it("opens below when there is enough space", () => {
189
+ jest.spyOn(input, "getBoundingClientRect").mockReturnValue({
190
+ top: 100,
191
+ bottom: 130
192
+ });
193
+
194
+ Reflect.defineProperty(window, "innerHeight", {
195
+ writable: true,
196
+ configurable: true,
197
+ value: 700
198
+ });
199
+
200
+ adjustPickerPosition(input, timePicker, ".datepicker__time-column");
201
+
202
+ expect(timePicker.style.top).toBe("30px");
203
+ expect(timePicker.style.bottom).toBe("");
204
+ });
205
+
206
+ it("opens above when there is not enough space below", () => {
207
+ jest.spyOn(input, "getBoundingClientRect").mockReturnValue({
208
+ top: 400,
209
+ bottom: 430
210
+ });
211
+
212
+ Reflect.defineProperty(window, "innerHeight", {
213
+ writable: true,
214
+ configurable: true,
215
+ value: 500
216
+ });
217
+
218
+ adjustPickerPosition(input, timePicker, ".datepicker__time-column");
219
+
220
+ expect(timePicker.style.top).toBe("");
221
+ expect(timePicker.style.bottom).toBe("30px");
222
+ });
223
+
224
+ it("always aligns to the right", () => {
225
+ jest.spyOn(input, "getBoundingClientRect").mockReturnValue({
226
+ top: 100,
227
+ bottom: 130
228
+ });
229
+
230
+ adjustPickerPosition(input, timePicker, ".datepicker__time-column");
231
+
232
+ expect(timePicker.style.right).toBe("0px");
233
+ });
234
+ });
@@ -80,7 +80,9 @@ export default Image.extend({
80
80
  addAttributes() {
81
81
  return {
82
82
  ...this.parent?.(),
83
- width: { default: null }
83
+ width: { default: null },
84
+ href: { default: null },
85
+ target: { default: null }
84
86
  };
85
87
  },
86
88
 
@@ -92,13 +94,16 @@ export default Image.extend({
92
94
  ...this.parent?.(),
93
95
  imageDialog: () => async ({ dispatch }) => {
94
96
  if (dispatch) {
95
- let { src, alt, width } = this.editor.getAttributes("image");
97
+ let { src, alt, width, href, target } = this.editor.getAttributes("image");
96
98
 
97
99
  this.editor.commands.toggleDialog(true);
98
- const dialogState = await uploadDialog.toggle({ src, alt }, {
99
- inputLabel: i18n.altLabel,
100
- uploadHandler: async (file) => uploadImage(file, this.options.uploadImagesPath)
101
- });
100
+ const dialogState = await uploadDialog.toggle(
101
+ { src, alt },
102
+ {
103
+ inputLabel: i18n.altLabel,
104
+ uploadHandler: async (file) => uploadImage(file, this.options.uploadImagesPath)
105
+ }
106
+ );
102
107
  this.editor.commands.toggleDialog(false);
103
108
 
104
109
  if (dialogState !== "save") {
@@ -113,8 +118,7 @@ export default Image.extend({
113
118
 
114
119
  src = uploadDialog.getValue("src");
115
120
  alt = uploadDialog.getValue("alt");
116
-
117
- return this.editor.chain().setImage({ src, alt, width }).focus(null, { scrollIntoView: false }).run();
121
+ return this.editor.chain().setImage({ src, alt, width, href, target }).focus(null, { scrollIntoView: false }).run();
118
122
  }
119
123
 
120
124
  return true;
@@ -127,18 +131,52 @@ export default Image.extend({
127
131
  },
128
132
 
129
133
  parseHTML() {
130
- return [{ tag: "div[data-image] img[src]:not([src^='data:'])" }];
134
+ return [
135
+ {
136
+ tag: "div[data-image] img[src]:not([src^='data:'])",
137
+ getAttrs: (dom) => {
138
+ // Check if the image's parent div is wrapped in a link
139
+ const imageDiv = dom.closest("div[data-image]");
140
+ const link = imageDiv?.parentElement;
141
+ if (link && link.tagName === "A" && link.hasAttribute("href")) {
142
+ const attrs = { href: link.getAttribute("href") };
143
+ if (link.hasAttribute("target")) {
144
+ attrs.target = link.getAttribute("target");
145
+ }
146
+ return attrs;
147
+ }
148
+ return {};
149
+ }
150
+ }
151
+ ];
131
152
  },
132
153
 
133
154
  renderHTML({ HTMLAttributes }) {
134
- return [
155
+ const { href, target, ...imgAttributes } = HTMLAttributes;
156
+ const imageContent = [
135
157
  "div",
136
158
  { "class": "editor-content-image", "data-image": "" },
137
159
  [
138
160
  "img",
139
- mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)
161
+ mergeAttributes(this.options.HTMLAttributes, imgAttributes)
140
162
  ]
141
163
  ];
164
+
165
+ if (href) {
166
+ const anchorAttributes = { href };
167
+ const anchorTarget = target;
168
+ if (anchorTarget) {
169
+ anchorAttributes.target = anchorTarget;
170
+ anchorAttributes.rel = "noopener noreferrer";
171
+ }
172
+ return [
173
+ "a",
174
+ anchorAttributes,
175
+ imageContent
176
+ ];
177
+ }
178
+
179
+ return imageContent;
142
180
  },
143
181
 
144
182
  addProseMirrorPlugins() {
@@ -62,7 +62,9 @@ export default (self) => {
62
62
  const img = contentDOM.querySelector("img");
63
63
  let activeResizeControl = null,
64
64
  currentHeight = null,
65
+ currentHref = node.attrs.href,
65
66
  currentSrc = node.attrs.src,
67
+ currentTarget = node.attrs.target,
66
68
  currentWidth = null,
67
69
  naturalHeight = img.naturalHeight,
68
70
  naturalWidth = img.naturalWidth,
@@ -185,7 +187,13 @@ export default (self) => {
185
187
  return false;
186
188
  }
187
189
 
188
- const { alt, src, title, width } = updatedNode.attrs;
190
+ const { alt, src, title, width, href, target } = updatedNode.attrs;
191
+
192
+ // If the href or target changed, we need to recreate the node because the
193
+ // structure changes (wrapped in <a> vs not wrapped)
194
+ if (href !== currentHref || target !== currentTarget) {
195
+ return false;
196
+ }
189
197
 
190
198
  // We set the value through an attribute change here because otherwise
191
199
  // we would trigger a mutation in the DOM which causes the update method
@@ -1,25 +1,53 @@
1
- import { PluginKey } from "prosemirror-state";
1
+ import { NodeSelection, PluginKey } from "prosemirror-state";
2
2
 
3
3
  import { getDictionary } from "src/decidim/refactor/moved/i18n";
4
4
  import BubbleMenu from "src/decidim/editor/common/bubble_menu";
5
5
 
6
6
  class LinkBubbleMenu extends BubbleMenu {
7
- shouldDisplay() {
8
- return this.editor.isActive("link");
7
+ shouldDisplay(view) {
8
+ if (this.editor.isActive("link")) {
9
+ return true;
10
+ }
11
+
12
+ const selection = view.state.selection;
13
+ return this.isImage(selection) && Boolean(selection.node.attrs.href);
9
14
  }
10
15
 
11
- display() {
12
- const { href } = this.editor.getAttributes("link");
13
- this.element.querySelector("[data-linkbubble-value]").textContent = href;
16
+ display(view) {
17
+ if (this.editor.isActive("link")) {
18
+ const { href } = this.editor.getAttributes("link");
19
+ this.updateHref(href);
20
+ return;
21
+ }
22
+
23
+ const selection = view.state.selection;
24
+ if (this.isImage(selection)) {
25
+ this.bubble.style.zIndex = "10";
26
+ this.updateHref(selection.node.attrs.href);
27
+ }
14
28
  }
15
29
 
16
30
  handleAction(action) {
17
31
  if (action === "remove") {
32
+ const { selection } = this.editor.state;
33
+ if (this.isImage(selection)) {
34
+ this.editor.chain().focus(null, { scrollIntoView: false }).updateAttributes("image", { href: null, target: null }).run();
35
+ return;
36
+ }
37
+
18
38
  this.editor.chain().focus(null, { scrollIntoView: false }).unsetLink().run();
19
39
  } else {
20
40
  this.editor.commands.linkDialog();
21
41
  }
22
42
  }
43
+
44
+ updateHref(href) {
45
+ this.element.querySelector("[data-linkbubble-value]").textContent = href;
46
+ }
47
+
48
+ isImage(selection) {
49
+ return selection instanceof NodeSelection && selection.node.type.name === "image";
50
+ }
23
51
  }
24
52
 
25
53
  const createElement = () => {
@@ -1,5 +1,5 @@
1
1
  import Link from "@tiptap/extension-link";
2
- import { Plugin } from "prosemirror-state";
2
+ import { Plugin, NodeSelection } from "prosemirror-state";
3
3
 
4
4
  import { getDictionary } from "src/decidim/refactor/moved/i18n";
5
5
  import InputDialog from "src/decidim/editor/common/input_dialog";
@@ -40,31 +40,55 @@ export default Link.extend({
40
40
  ...this.parent?.(),
41
41
 
42
42
  toggleLinkBubble: () => ({ dispatch }) => {
43
+ const { selection } = this.editor.state;
44
+ const isImageSelection = selection instanceof NodeSelection && selection.node.type.name === "image";
45
+ const imageHasLink = isImageSelection && Boolean(selection.node.attrs.href);
46
+
43
47
  if (dispatch) {
44
- if (this.editor.isActive("link")) {
45
- this.storage.bubbleMenu.show();
48
+ if (this.editor.isActive("link") || (isImageSelection && imageHasLink)) {
49
+ this.storage.bubbleMenu.handleSelectionChange(this.editor.view);
46
50
  return true;
47
51
  }
48
52
 
49
53
  this.storage.bubbleMenu.hide();
50
54
  return false;
51
55
  }
56
+
57
+ if (isImageSelection) {
58
+ return imageHasLink;
59
+ }
60
+
52
61
  return this.editor.isActive("link");
53
62
  },
54
63
 
55
64
  linkDialog: () => async ({ dispatch, commands }) => {
56
65
  if (dispatch) {
57
- // If the cursor is within the link but the link is not selected, the
58
- // link would not be correctly updated. Also if only a part of the
59
- // link is selected, the link would be split to separate links, only
60
- // the current selection getting the updated link URL.
61
- commands.extendMarkRange("link");
66
+ const { state } = this.editor;
67
+ const { selection } = state;
68
+ const isImageSelection = selection instanceof NodeSelection && selection.node.type.name === "image";
69
+ const nodeType = (() => {
70
+ if (isImageSelection) {
71
+ return "image";
72
+ }
73
+ return "link";
74
+ })();
75
+
76
+ if (!isImageSelection) {
77
+ // If the cursor is within the link but the link is not selected, the
78
+ // link would not be correctly updated. Also if only a part of the
79
+ // link is selected, the link would be split to separate links, only
80
+ // the current selection getting the updated link URL.
81
+ commands.extendMarkRange("link");
82
+ }
62
83
 
63
84
  this.storage.bubbleMenu.hide();
64
85
 
65
86
  const { allowTargetControl } = this.options;
66
87
 
67
- let { href, target } = this.editor.getAttributes("link");
88
+ let href = null;
89
+ let target = null;
90
+
91
+ ({ href, target } = this.editor.getAttributes(nodeType));
68
92
 
69
93
  const inputs = { href: { type: "text", label: i18n.hrefLabel } };
70
94
  if (allowTargetControl) {
@@ -88,16 +112,25 @@ export default Link.extend({
88
112
  target = null;
89
113
  }
90
114
 
115
+ const buildChain = () => this.editor.chain().focus(null, { scrollIntoView: false });
116
+
91
117
  if (dialogState !== "save") {
92
- this.editor.chain().focus(null, { scrollIntoView: false }).toggleLinkBubble().run();
118
+ buildChain().toggleLinkBubble().run();
93
119
  return false;
94
120
  }
95
121
 
96
122
  if (!href || href.trim().length < 1) {
97
- return this.editor.chain().focus(null, { scrollIntoView: false }).unsetLink().run();
123
+ if (isImageSelection) {
124
+ return buildChain().updateAttributes("image", { href: null, target: null }).run();
125
+ }
126
+ return buildChain().unsetLink().run();
127
+ }
128
+
129
+ if (isImageSelection) {
130
+ return buildChain().updateAttributes("image", { href: href, target }).toggleLinkBubble().run();
98
131
  }
99
132
 
100
- return this.editor.chain().focus(null, { scrollIntoView: false }).setLink({ href, target }).toggleLinkBubble().run();
133
+ return buildChain().setLink({ href: href, target }).toggleLinkBubble().run();
101
134
  }
102
135
 
103
136
  return true;