decidim-collaborative_texts 0.31.0.rc1
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.
- checksums.yaml +7 -0
- data/README.md +24 -0
- data/Rakefile +3 -0
- data/app/cells/decidim/collaborative_texts/document_cell.rb +23 -0
- data/app/cells/decidim/collaborative_texts/document_l_cell.rb +15 -0
- data/app/commands/decidim/collaborative_texts/admin/create_document.rb +25 -0
- data/app/commands/decidim/collaborative_texts/admin/publish_document.rb +52 -0
- data/app/commands/decidim/collaborative_texts/admin/unpublish_document.rb +44 -0
- data/app/commands/decidim/collaborative_texts/admin/update_document.rb +101 -0
- data/app/commands/decidim/collaborative_texts/admin/update_document_settings.rb +13 -0
- data/app/commands/decidim/collaborative_texts/create_suggestion.rb +28 -0
- data/app/commands/decidim/collaborative_texts/rollout.rb +50 -0
- data/app/controllers/concerns/decidim/collaborative_texts/admin/filterable.rb +23 -0
- data/app/controllers/decidim/collaborative_texts/admin/application_controller.rb +15 -0
- data/app/controllers/decidim/collaborative_texts/admin/documents_controller.rb +142 -0
- data/app/controllers/decidim/collaborative_texts/application_controller.rb +14 -0
- data/app/controllers/decidim/collaborative_texts/documents_controller.rb +57 -0
- data/app/controllers/decidim/collaborative_texts/suggestions_controller.rb +55 -0
- data/app/events/decidim/collaborative_texts/suggestion_accepted_event.rb +6 -0
- data/app/forms/decidim/collaborative_texts/admin/document_form.rb +29 -0
- data/app/forms/decidim/collaborative_texts/rollout_form.rb +26 -0
- data/app/forms/decidim/collaborative_texts/suggestion_form.rb +42 -0
- data/app/helpers/decidim/collaborative_texts/application_helper.rb +20 -0
- data/app/models/decidim/collaborative_texts/application_record.rb +10 -0
- data/app/models/decidim/collaborative_texts/document.rb +78 -0
- data/app/models/decidim/collaborative_texts/suggestion.rb +36 -0
- data/app/models/decidim/collaborative_texts/version.rb +35 -0
- data/app/packs/entrypoints/decidim_collaborative_texts.js +7 -0
- data/app/packs/images/decidim/collaborative_texts/decidim_collaborative_texts.svg +1 -0
- data/app/packs/src/decidim/collaborative_texts/document.js +168 -0
- data/app/packs/src/decidim/collaborative_texts/editor.js +80 -0
- data/app/packs/src/decidim/collaborative_texts/init_documents.js +27 -0
- data/app/packs/src/decidim/collaborative_texts/manager.js +106 -0
- data/app/packs/src/decidim/collaborative_texts/selection.js +106 -0
- data/app/packs/src/decidim/collaborative_texts/suggestion.js +243 -0
- data/app/packs/src/decidim/collaborative_texts/suggestions_list.js +103 -0
- data/app/packs/src/decidim/collaborative_texts/test/document.test.js +83 -0
- data/app/packs/src/decidim/collaborative_texts/test/manager.test.js +149 -0
- data/app/packs/src/decidim/collaborative_texts/test/selection.test.js +125 -0
- data/app/packs/src/decidim/collaborative_texts/test/suggestions.test.js +233 -0
- data/app/packs/src/decidim/collaborative_texts/test/toc.test.js +70 -0
- data/app/packs/src/decidim/collaborative_texts/toc.js +48 -0
- data/app/packs/stylesheets/decidim/collaborative_texts/collaborative_texts.scss +287 -0
- data/app/permissions/decidim/collaborative_texts/admin/permissions.rb +28 -0
- data/app/permissions/decidim/collaborative_texts/permissions.rb +36 -0
- data/app/presenters/decidim/collaborative_texts/admin_log/document_presenter.rb +54 -0
- data/app/presenters/decidim/collaborative_texts/admin_log/suggestion_presenter.rb +62 -0
- data/app/presenters/decidim/collaborative_texts/admin_log/suggestion_resource_presenter.rb +20 -0
- data/app/presenters/decidim/collaborative_texts/admin_log/version_presenter.rb +53 -0
- data/app/presenters/decidim/collaborative_texts/official_author_presenter.rb +11 -0
- data/app/presenters/decidim/collaborative_texts/suggestion_presenter.rb +57 -0
- data/app/views/decidim/collaborative_texts/admin/documents/_actions.html.erb +82 -0
- data/app/views/decidim/collaborative_texts/admin/documents/_document-tr.html.erb +15 -0
- data/app/views/decidim/collaborative_texts/admin/documents/_documents-thead.html.erb +7 -0
- data/app/views/decidim/collaborative_texts/admin/documents/_draft_options.html.erb +6 -0
- data/app/views/decidim/collaborative_texts/admin/documents/_form.html.erb +16 -0
- data/app/views/decidim/collaborative_texts/admin/documents/_non_draft_options.html.erb +9 -0
- data/app/views/decidim/collaborative_texts/admin/documents/_versions.html.erb +18 -0
- data/app/views/decidim/collaborative_texts/admin/documents/edit.html.erb +33 -0
- data/app/views/decidim/collaborative_texts/admin/documents/edit_settings.html.erb +18 -0
- data/app/views/decidim/collaborative_texts/admin/documents/index.html.erb +32 -0
- data/app/views/decidim/collaborative_texts/admin/documents/manage_trash.html.erb +19 -0
- data/app/views/decidim/collaborative_texts/admin/documents/new.html.erb +18 -0
- data/app/views/decidim/collaborative_texts/admin/settings/_form.html.erb +9 -0
- data/app/views/decidim/collaborative_texts/documents/_editor_template.html.erb +15 -0
- data/app/views/decidim/collaborative_texts/documents/_manager.html.erb +9 -0
- data/app/views/decidim/collaborative_texts/documents/_suggestions_box_item_template.html.erb +33 -0
- data/app/views/decidim/collaborative_texts/documents/_suggestions_box_template.html.erb +20 -0
- data/app/views/decidim/collaborative_texts/documents/index.html.erb +29 -0
- data/app/views/decidim/collaborative_texts/documents/show.html.erb +70 -0
- data/config/assets.rb +8 -0
- data/config/locales/am-ET.yml +1 -0
- data/config/locales/ar.yml +1 -0
- data/config/locales/bg.yml +1 -0
- data/config/locales/bn-BD.yml +1 -0
- data/config/locales/bs-BA.yml +1 -0
- data/config/locales/ca-IT.yml +154 -0
- data/config/locales/ca.yml +154 -0
- data/config/locales/cs.yml +122 -0
- data/config/locales/da.yml +1 -0
- data/config/locales/de.yml +154 -0
- data/config/locales/el.yml +1 -0
- data/config/locales/en.yml +154 -0
- data/config/locales/eo.yml +1 -0
- data/config/locales/es-MX.yml +154 -0
- data/config/locales/es-PY.yml +154 -0
- data/config/locales/es.yml +154 -0
- data/config/locales/et.yml +1 -0
- data/config/locales/eu.yml +154 -0
- data/config/locales/fa-IR.yml +1 -0
- data/config/locales/fi-plain.yml +154 -0
- data/config/locales/fi.yml +154 -0
- data/config/locales/fr-CA.yml +125 -0
- data/config/locales/fr.yml +125 -0
- data/config/locales/ga-IE.yml +1 -0
- data/config/locales/gl.yml +1 -0
- data/config/locales/gn-PY.yml +1 -0
- data/config/locales/he-IL.yml +1 -0
- data/config/locales/hr.yml +1 -0
- data/config/locales/hu.yml +1 -0
- data/config/locales/id-ID.yml +1 -0
- data/config/locales/is-IS.yml +1 -0
- data/config/locales/it.yml +1 -0
- data/config/locales/ja.yml +153 -0
- data/config/locales/ka-GE.yml +1 -0
- data/config/locales/kaa.yml +1 -0
- data/config/locales/ko.yml +1 -0
- data/config/locales/lb.yml +1 -0
- data/config/locales/lo-LA.yml +1 -0
- data/config/locales/lt.yml +1 -0
- data/config/locales/lv.yml +1 -0
- data/config/locales/mt.yml +1 -0
- data/config/locales/nl.yml +1 -0
- data/config/locales/no.yml +6 -0
- data/config/locales/oc-FR.yml +1 -0
- data/config/locales/om-ET.yml +1 -0
- data/config/locales/pl.yml +1 -0
- data/config/locales/pt-BR.yml +1 -0
- data/config/locales/pt.yml +1 -0
- data/config/locales/ro-RO.yml +89 -0
- data/config/locales/ru.yml +1 -0
- data/config/locales/si-LK.yml +1 -0
- data/config/locales/sk.yml +1 -0
- data/config/locales/sl.yml +1 -0
- data/config/locales/so-SO.yml +1 -0
- data/config/locales/sq-AL.yml +1 -0
- data/config/locales/sr-CS.yml +1 -0
- data/config/locales/sv.yml +117 -0
- data/config/locales/sw-KE.yml +1 -0
- data/config/locales/th-TH.yml +1 -0
- data/config/locales/ti-ER.yml +1 -0
- data/config/locales/tr-TR.yml +69 -0
- data/config/locales/uk.yml +1 -0
- data/config/locales/val-ES.yml +1 -0
- data/config/locales/vi.yml +1 -0
- data/config/locales/zh-CN.yml +1 -0
- data/config/locales/zh-TW.yml +1 -0
- data/db/migrate/20250205215038_create_decidim_collaborative_texts_documents.rb +16 -0
- data/db/migrate/20250213113536_create_collaborative_texts_versions.rb +13 -0
- data/db/migrate/20250227204839_create_collaborative_texts_suggestions.rb +13 -0
- data/db/migrate/20250312140133_add_counter_caches_to_collaborative_texts_documents.rb +13 -0
- data/db/migrate/20250408205231_add_counter_caches_to_collaborative_text_versions.rb +8 -0
- data/decidim-collaborative_texts.gemspec +36 -0
- data/lib/decidim/api/document_input_filter.rb +29 -0
- data/lib/decidim/api/document_input_sort.rb +14 -0
- data/lib/decidim/api/document_type.rb +31 -0
- data/lib/decidim/api/documents_type.rb +39 -0
- data/lib/decidim/api/suggestion_type.rb +18 -0
- data/lib/decidim/api/version_type.rb +21 -0
- data/lib/decidim/collaborative_texts/admin.rb +10 -0
- data/lib/decidim/collaborative_texts/admin_engine.rb +34 -0
- data/lib/decidim/collaborative_texts/api.rb +12 -0
- data/lib/decidim/collaborative_texts/component.rb +54 -0
- data/lib/decidim/collaborative_texts/engine.rb +28 -0
- data/lib/decidim/collaborative_texts/seeds.rb +117 -0
- data/lib/decidim/collaborative_texts/test/factories.rb +80 -0
- data/lib/decidim/collaborative_texts/version.rb +9 -0
- data/lib/decidim/collaborative_texts.rb +13 -0
- metadata +233 -0
@@ -0,0 +1,106 @@
|
|
1
|
+
import confirmDialog from "src/decidim/confirm";
|
2
|
+
export default class Manager {
|
3
|
+
constructor(document) {
|
4
|
+
this.document = document;
|
5
|
+
this.suggestions = document.suggestionsList.suggestions;
|
6
|
+
this.doc = document.doc;
|
7
|
+
this.i18n = document.i18n || {};
|
8
|
+
this.div = window.document.getElementsByClassName("collaborative-texts-manager")[0];
|
9
|
+
this.rolloutButton = this.div.querySelector("[data-collaborative-texts-manager-rollout]");
|
10
|
+
this.consolidateButton = this.div.querySelector("[data-collaborative-texts-manager-consolidate]");
|
11
|
+
this.cancelButton = this.div.querySelector("[data-collaborative-texts-manager-cancel]");
|
12
|
+
this.counters = {
|
13
|
+
applied: [...this.div.getElementsByClassName("collaborative-texts-manager-applied")],
|
14
|
+
pending: [...this.div.getElementsByClassName("collaborative-texts-manager-pending")]
|
15
|
+
};
|
16
|
+
this._bindEvents();
|
17
|
+
}
|
18
|
+
|
19
|
+
updateCounters(applied, pending) {
|
20
|
+
this.counters.applied.forEach((counter) => {
|
21
|
+
counter.textContent = applied;
|
22
|
+
});
|
23
|
+
this.counters.pending.forEach((counter) => {
|
24
|
+
counter.textContent = pending;
|
25
|
+
});
|
26
|
+
}
|
27
|
+
|
28
|
+
show() {
|
29
|
+
if (this.div) {
|
30
|
+
this.div.classList.remove("hidden");
|
31
|
+
}
|
32
|
+
}
|
33
|
+
|
34
|
+
hide() {
|
35
|
+
this.div.classList.add("hidden");
|
36
|
+
}
|
37
|
+
|
38
|
+
cancel() {
|
39
|
+
this.suggestions.forEach((suggestion) => suggestion.restore());
|
40
|
+
this.hide();
|
41
|
+
}
|
42
|
+
|
43
|
+
// Duplicates the body of the document, removing non accepted suggestions
|
44
|
+
// Accepted suggestions nodes are converted to first level nodes
|
45
|
+
cleanBody() {
|
46
|
+
this.container = window.document.createElement("div");
|
47
|
+
this.doc.childNodes.forEach((node) => {
|
48
|
+
if (node.nodeType !== Node.ELEMENT_NODE) {
|
49
|
+
return;
|
50
|
+
}
|
51
|
+
if (node.classList.contains("collaborative-texts-changes")) {
|
52
|
+
node.childNodes.forEach((child) => this.container.appendChild(child.cloneNode(true)));
|
53
|
+
return;
|
54
|
+
}
|
55
|
+
if ([...node.classList].find((cls) => cls.startsWith("collaborative-texts-"))) {
|
56
|
+
return;
|
57
|
+
}
|
58
|
+
this.container.appendChild(node.cloneNode(true));
|
59
|
+
});
|
60
|
+
return this.container.innerHTML;
|
61
|
+
}
|
62
|
+
|
63
|
+
_save(event) {
|
64
|
+
const draft = !event.target.dataset.collaborativeTextsManagerConsolidate;
|
65
|
+
confirmDialog(draft
|
66
|
+
? this.i18n.rolloutConfirm
|
67
|
+
: this.i18n.consolidateConfirm).then((accepted) => {
|
68
|
+
if (accepted) {
|
69
|
+
fetch(this.doc.dataset.collaborativeTextsRolloutUrl, {
|
70
|
+
method: "PATCH",
|
71
|
+
headers: {
|
72
|
+
"Content-Type": "application/json",
|
73
|
+
"X-Requested-With": "XMLHttpRequest",
|
74
|
+
"X-CSRF-Token": this.document.csrfToken
|
75
|
+
},
|
76
|
+
body: JSON.stringify({
|
77
|
+
body: this.cleanBody(),
|
78
|
+
accepted: this.document.suggestionsList.getApplied().map((suggestion) => suggestion.id),
|
79
|
+
pending: this.document.suggestionsList.getPending().map((suggestion) => suggestion.id),
|
80
|
+
draft: draft
|
81
|
+
})
|
82
|
+
}).
|
83
|
+
then(async (response) => {
|
84
|
+
const data = await response.json();
|
85
|
+
if (!response.ok) {
|
86
|
+
throw new Error(data.message
|
87
|
+
? data.message
|
88
|
+
: data);
|
89
|
+
}
|
90
|
+
window.location.href = data.redirect;
|
91
|
+
}).
|
92
|
+
catch((error) => {
|
93
|
+
console.error("Error saving:", error);
|
94
|
+
this.document.alert(error);
|
95
|
+
});
|
96
|
+
}
|
97
|
+
});
|
98
|
+
|
99
|
+
}
|
100
|
+
|
101
|
+
_bindEvents() {
|
102
|
+
this.rolloutButton.addEventListener("click", this._save.bind(this));
|
103
|
+
this.consolidateButton.addEventListener("click", this._save.bind(this));
|
104
|
+
this.cancelButton.addEventListener("click", this.cancel.bind(this));
|
105
|
+
}
|
106
|
+
}
|
@@ -0,0 +1,106 @@
|
|
1
|
+
import Editor from "src/decidim/collaborative_texts/editor";
|
2
|
+
|
3
|
+
class Selection {
|
4
|
+
constructor(doc) {
|
5
|
+
this.doc = doc;
|
6
|
+
this.selection = window.document.getSelection();
|
7
|
+
this.nodes = [];
|
8
|
+
this.firstNode = null;
|
9
|
+
this.lastNode = null;
|
10
|
+
this.wrapper = null;
|
11
|
+
this.editor = null;
|
12
|
+
this.blocked = false;
|
13
|
+
}
|
14
|
+
|
15
|
+
getRanges() {
|
16
|
+
const ranges = [];
|
17
|
+
for (let idx = 0; idx < this.selection.rangeCount; idx++) { // eslint-disable-line no-plusplus
|
18
|
+
ranges.push(this.selection.getRangeAt(idx));
|
19
|
+
}
|
20
|
+
return ranges;
|
21
|
+
}
|
22
|
+
|
23
|
+
isEditing() {
|
24
|
+
if (!this.blocked) {
|
25
|
+
return false;
|
26
|
+
}
|
27
|
+
if (this.changed()) {
|
28
|
+
return true;
|
29
|
+
}
|
30
|
+
return false;
|
31
|
+
}
|
32
|
+
|
33
|
+
isValid() {
|
34
|
+
if (this.selection.rangeCount === 0 || this.selection.isCollapsed) {
|
35
|
+
return false;
|
36
|
+
}
|
37
|
+
this.detectNodes();
|
38
|
+
if (this.nodes.length === 0) {
|
39
|
+
return false;
|
40
|
+
}
|
41
|
+
return true;
|
42
|
+
}
|
43
|
+
|
44
|
+
detectNodes() {
|
45
|
+
this.nodes = [];
|
46
|
+
this.firstNode = null;
|
47
|
+
this.lastNode = null;
|
48
|
+
this.getRanges().forEach((range) => {
|
49
|
+
this.doc.nodes.forEach((node, index) => {
|
50
|
+
if (range.intersectsNode(node)) {
|
51
|
+
this.nodes[index] = node;
|
52
|
+
this.firstNode = this.firstNode || node;
|
53
|
+
this.lastNode = node;
|
54
|
+
}
|
55
|
+
});
|
56
|
+
});
|
57
|
+
return this;
|
58
|
+
}
|
59
|
+
|
60
|
+
changed() {
|
61
|
+
return this.editor && !this.editor.saveButton.disabled;
|
62
|
+
}
|
63
|
+
|
64
|
+
scrollIntoView() {
|
65
|
+
if (this.editor) {
|
66
|
+
this.editor.editor.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
67
|
+
}
|
68
|
+
return this;
|
69
|
+
}
|
70
|
+
|
71
|
+
wrap() {
|
72
|
+
this.blocked = true;
|
73
|
+
this.wrapper = window.document.createElement("div");
|
74
|
+
this.wrapper.classList.add("collaborative-texts-selection");
|
75
|
+
this.firstNode.before(this.wrapper);
|
76
|
+
this.nodes.forEach((node) => this.wrapper.appendChild(node));
|
77
|
+
return this;
|
78
|
+
}
|
79
|
+
|
80
|
+
unWrap() {
|
81
|
+
if (this.wrapper) {
|
82
|
+
while (this.wrapper.firstChild) {
|
83
|
+
this.wrapper.parentNode.insertBefore(this.wrapper.firstChild, this.wrapper);
|
84
|
+
}
|
85
|
+
this.wrapper.remove();
|
86
|
+
}
|
87
|
+
return this;
|
88
|
+
}
|
89
|
+
|
90
|
+
|
91
|
+
showEditor() {
|
92
|
+
this.editor = new Editor(this);
|
93
|
+
return this;
|
94
|
+
}
|
95
|
+
|
96
|
+
clear() {
|
97
|
+
this.unWrap();
|
98
|
+
if (this.editor) {
|
99
|
+
this.editor.destroy();
|
100
|
+
}
|
101
|
+
this.blocked = false;
|
102
|
+
return this;
|
103
|
+
}
|
104
|
+
}
|
105
|
+
|
106
|
+
export default Selection;
|
@@ -0,0 +1,243 @@
|
|
1
|
+
export default class Suggestion {
|
2
|
+
constructor(suggestionsList, entry) {
|
3
|
+
this.id = entry.id;
|
4
|
+
this.authorCell = entry.profileHtml;
|
5
|
+
this.suggestionsList = suggestionsList;
|
6
|
+
this.selection = suggestionsList.document.selection;
|
7
|
+
this.templates = suggestionsList.document.templates;
|
8
|
+
this.doc = suggestionsList.doc;
|
9
|
+
this.nodes = [];
|
10
|
+
for (const node of suggestionsList.nodes) {
|
11
|
+
if (node.id === `ct-node-${entry.changeset.firstNode}`) {
|
12
|
+
this.firstNode = node;
|
13
|
+
}
|
14
|
+
if (node.id === `ct-node-${entry.changeset.lastNode}`) {
|
15
|
+
this.lastNode = node;
|
16
|
+
}
|
17
|
+
if (this.firstNode) {
|
18
|
+
this.nodes.push(node);
|
19
|
+
}
|
20
|
+
if (this.lastNode) {
|
21
|
+
break;
|
22
|
+
}
|
23
|
+
}
|
24
|
+
this.replace = entry.changeset.replace;
|
25
|
+
this.summary = entry.summary;
|
26
|
+
this.item = null;
|
27
|
+
this.boxWrapper = null;
|
28
|
+
this.highlightWrapper = null;
|
29
|
+
this.changesWrapper = null;
|
30
|
+
this.applied = false;
|
31
|
+
this.isFirst = false;
|
32
|
+
this.valid = this.nodes.length > 0 && this.firstNode && this.lastNode && Array.isArray(this.replace);
|
33
|
+
if (this.valid) {
|
34
|
+
this._createBoxWrapper();
|
35
|
+
this._createBoxItem();
|
36
|
+
this._bindEvents();
|
37
|
+
}
|
38
|
+
}
|
39
|
+
|
40
|
+
// Get the nodes that are affected by the suggestion
|
41
|
+
siblingSuggestions() {
|
42
|
+
return this.suggestionsList.suggestions.filter((suggestion) => {
|
43
|
+
return suggestion.id !== this.id && suggestion.nodes.some((node) => this.nodes.includes(node));
|
44
|
+
});
|
45
|
+
}
|
46
|
+
|
47
|
+
// wrap affected nodes by the suggestion in a div to temporarily apply the changes
|
48
|
+
// this is used to highlight (or replace) the nodes when hovering the suggestion
|
49
|
+
highlight() {
|
50
|
+
// If applied, just add the style to the changesWrapper
|
51
|
+
if (this.applied) {
|
52
|
+
this.changesWrapper.classList.add("collaborative-texts-highlight");
|
53
|
+
return;
|
54
|
+
}
|
55
|
+
this.highlightWrapper = window.document.createElement("div");
|
56
|
+
this.highlightWrapper.classList.add("collaborative-texts-highlight");
|
57
|
+
this.firstNode.before(this.highlightWrapper);
|
58
|
+
this._hideOriginalNodes("collaborative-texts-highlight-hidden");
|
59
|
+
this.siblingSuggestions().filter((suggestion) => suggestion.applied).forEach((suggestion) => {
|
60
|
+
suggestion.changesWrapper.classList.add("collaborative-texts-highlight-hidden");
|
61
|
+
suggestion.nodes.forEach((node) => node.classList.add("collaborative-texts-highlight-shown"));
|
62
|
+
});
|
63
|
+
this._applyTo(this.highlightWrapper);
|
64
|
+
}
|
65
|
+
|
66
|
+
// Restores the highlighted nodes to their original state
|
67
|
+
// this is used to remove the highlight when leaving the suggestion
|
68
|
+
blur() {
|
69
|
+
// If applied, just remove the style from the changesWrapper
|
70
|
+
if (this.applied && this.changesWrapper) {
|
71
|
+
this.changesWrapper.classList.remove("collaborative-texts-highlight");
|
72
|
+
}
|
73
|
+
if (this.highlightWrapper) {
|
74
|
+
this.highlightWrapper.remove();
|
75
|
+
this.highlightWrapper = null;
|
76
|
+
this._showOriginalNodes("collaborative-texts-highlight-hidden");
|
77
|
+
this.siblingSuggestions().filter((suggestion) => suggestion.applied).forEach((suggestion) => {
|
78
|
+
suggestion.changesWrapper.classList.remove("collaborative-texts-highlight-hidden");
|
79
|
+
suggestion.nodes.forEach((node) => node.classList.remove("collaborative-texts-highlight-shown"));
|
80
|
+
});
|
81
|
+
}
|
82
|
+
}
|
83
|
+
|
84
|
+
// Apply the suggestion by replacing the nodes with the replace content
|
85
|
+
apply() {
|
86
|
+
if (!this.applied && !this.selection || !this.selection.blocked) {
|
87
|
+
this.applied = true;
|
88
|
+
if (this.highlightWrapper) {
|
89
|
+
this.blur();
|
90
|
+
}
|
91
|
+
// restore any other changes affecting the same nodes
|
92
|
+
this.suggestionsList.restore(this.nodes, [this]);
|
93
|
+
this._createChangesWrapper();
|
94
|
+
this._hideOriginalNodes();
|
95
|
+
this._applyTo(this.changesWrapper);
|
96
|
+
this.item.classList.add("applied");
|
97
|
+
this._resetAccordion();
|
98
|
+
let event = new CustomEvent("collaborative-texts:applied", { detail: { suggestion: this } });
|
99
|
+
this.doc.dispatchEvent(event);
|
100
|
+
}
|
101
|
+
return this;
|
102
|
+
}
|
103
|
+
|
104
|
+
// Restore the suggestion by removing the replace content and showing the original nodes
|
105
|
+
restore() {
|
106
|
+
if (this.applied) {
|
107
|
+
this.applied = false;
|
108
|
+
this.item.classList.remove("applied");
|
109
|
+
this._showOriginalNodes();
|
110
|
+
this.changesWrapper.remove();
|
111
|
+
this.changesWrapper = null;
|
112
|
+
this._resetAccordion();
|
113
|
+
let event = new CustomEvent("collaborative-texts:restored", { detail: { suggestion: this } });
|
114
|
+
this.doc.dispatchEvent(event);
|
115
|
+
}
|
116
|
+
return this;
|
117
|
+
}
|
118
|
+
|
119
|
+
// Apply the changes to the wrapper passed as argument
|
120
|
+
// this does not manipulate the DOM, but only the wrapper
|
121
|
+
_applyTo(wrapper) {
|
122
|
+
this.replace.forEach((text) => wrapper.insertAdjacentHTML("beforeend", text));
|
123
|
+
// wrap in <p> any text nodes
|
124
|
+
wrapper.childNodes.forEach((node) => {
|
125
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
126
|
+
let paragraph = window.document.createElement("p");
|
127
|
+
paragraph.textContent = node.textContent;
|
128
|
+
node.replaceWith(paragraph);
|
129
|
+
}
|
130
|
+
});
|
131
|
+
}
|
132
|
+
|
133
|
+
_hideOriginalNodes(className = "collaborative-texts-hidden") {
|
134
|
+
this.nodes.forEach((node) => node.classList.add(className));
|
135
|
+
}
|
136
|
+
|
137
|
+
_showOriginalNodes(className = "collaborative-texts-hidden") {
|
138
|
+
this.nodes.forEach((node) => node.classList.remove(className));
|
139
|
+
}
|
140
|
+
|
141
|
+
getPosition() {
|
142
|
+
let node = this.changesWrapper || this.highlightWrapper || this.firstNode;
|
143
|
+
|
144
|
+
while (node.classList.contains("collaborative-texts-hidden") || node.classList.contains("hidden")) {
|
145
|
+
node = node.previousSibling;
|
146
|
+
}
|
147
|
+
|
148
|
+
return node.offsetTop;
|
149
|
+
}
|
150
|
+
|
151
|
+
// Reset the position of the suggestion boxWrapper using the current position of the first node
|
152
|
+
setPosition(position) {
|
153
|
+
if (!this.boxWrapper) {
|
154
|
+
return this;
|
155
|
+
}
|
156
|
+
this.boxWrapper.style.top = `${position}px`;
|
157
|
+
return this;
|
158
|
+
}
|
159
|
+
|
160
|
+
destroy() {
|
161
|
+
if (this.item) {
|
162
|
+
this.item.remove();
|
163
|
+
this.item = null;
|
164
|
+
}
|
165
|
+
if (this.boxWrapper) {
|
166
|
+
this.boxWrapper.remove();
|
167
|
+
this.boxWrapper = null;
|
168
|
+
}
|
169
|
+
if (this.highlightWrapper) {
|
170
|
+
this.highlightWrapper.remove();
|
171
|
+
this.highlightWrapper = null;
|
172
|
+
}
|
173
|
+
if (this.changesWrapper) {
|
174
|
+
this.changesWrapper.remove();
|
175
|
+
this.changesWrapper = null;
|
176
|
+
}
|
177
|
+
}
|
178
|
+
|
179
|
+
// Create a wrapper where to put the changes when applying the suggestion
|
180
|
+
// Replaced nodes will be hidden, and the changes will be inserted in the wrapper
|
181
|
+
_createChangesWrapper() {
|
182
|
+
this.changesWrapper = window.document.createElement("div");
|
183
|
+
this.changesWrapper.classList.add("collaborative-texts-changes");
|
184
|
+
this.firstNode.before(this.changesWrapper);
|
185
|
+
}
|
186
|
+
|
187
|
+
// Because the A11y accordion has not an API to programmatically toggle the
|
188
|
+
// accordion, we need to destroy and recreate it to reset the state
|
189
|
+
_resetAccordion() {
|
190
|
+
let accordion = this.boxWrapper.querySelector('[data-controller="accordion"]');
|
191
|
+
|
192
|
+
accordion.dispatchEvent(new CustomEvent("accordion:reconnect", { detail: { collapse: true } }));
|
193
|
+
}
|
194
|
+
|
195
|
+
// Create a wrapper for all the suggestions applying to the same nodes
|
196
|
+
_createBoxWrapper() {
|
197
|
+
if (this.firstNode.previousSibling &&
|
198
|
+
this.firstNode.previousSibling.nodeType === Node.ELEMENT_NODE &&
|
199
|
+
this.firstNode.previousSibling.classList.contains("collaborative-texts-suggestions-box")) {
|
200
|
+
this.boxWrapper = this.firstNode.previousSibling;
|
201
|
+
return;
|
202
|
+
}
|
203
|
+
this.isFirst = true;
|
204
|
+
this.boxWrapper = window.document.createElement("div");
|
205
|
+
this.boxWrapper.id = `suggestion-box-wrapper-${this.id}`;
|
206
|
+
this.boxWrapper.classList.add("collaborative-texts-suggestions-box");
|
207
|
+
this.boxWrapper.innerHTML = this.templates.suggestionsBox.innerHTML.replaceAll("{{ID}}", this.id);
|
208
|
+
this.firstNode.before(this.boxWrapper);
|
209
|
+
this._resetAccordion();
|
210
|
+
}
|
211
|
+
|
212
|
+
// Create the box item for the suggestion inside the wrapper
|
213
|
+
_createBoxItem() {
|
214
|
+
this.boxItems = this.boxWrapper.querySelector(".collaborative-texts-suggestions-box-items");
|
215
|
+
this.itemsCounts = this.boxWrapper.querySelectorAll(".collaborative-texts-suggestions-box-items-count");
|
216
|
+
this.item = window.document.createElement("div");
|
217
|
+
this.item.classList.add("collaborative-texts-suggestions-box-item");
|
218
|
+
this.item.innerHTML = this.templates.suggestionsBoxItem.innerHTML.replaceAll("{{ID}}", this.id).replaceAll("{{PROFILE}}", this.authorCell);
|
219
|
+
this.text = this.item.querySelector(".collaborative-texts-suggestions-box-item-text");
|
220
|
+
this.text.innerHTML = this.summary;
|
221
|
+
this.boxItems.appendChild(this.item);
|
222
|
+
if (!this.doc.dataset.collaborativeTextsRolloutUrl) {
|
223
|
+
this.dropdown = this.item.querySelector('[data-controller="dropdown"]');
|
224
|
+
if (this.dropdown) {
|
225
|
+
this.dropdown.remove();
|
226
|
+
}
|
227
|
+
}
|
228
|
+
this.itemsCounts.forEach((item) => {
|
229
|
+
item.textContent = this.boxItems.childElementCount;
|
230
|
+
});
|
231
|
+
}
|
232
|
+
|
233
|
+
_resetDropdown() {
|
234
|
+
|
235
|
+
}
|
236
|
+
|
237
|
+
_bindEvents() {
|
238
|
+
this.item.addEventListener("mouseenter", this.highlight.bind(this));
|
239
|
+
this.item.addEventListener("mouseleave", this.blur.bind(this));
|
240
|
+
this.item.querySelector(".button-apply").addEventListener("click", this.apply.bind(this));
|
241
|
+
this.item.querySelector(".button-restore").addEventListener("click", this.restore.bind(this));
|
242
|
+
}
|
243
|
+
}
|
@@ -0,0 +1,103 @@
|
|
1
|
+
import Suggestion from "src/decidim/collaborative_texts/suggestion";
|
2
|
+
|
3
|
+
export default class SuggestionsList {
|
4
|
+
constructor(document) {
|
5
|
+
this.document = document;
|
6
|
+
this.nodes = document.nodes;
|
7
|
+
this.doc = document.doc;
|
8
|
+
this.i18n = document.i18n || {};
|
9
|
+
this.suggestions = [];
|
10
|
+
this._fetchSuggestions();
|
11
|
+
this._bindEvents();
|
12
|
+
}
|
13
|
+
|
14
|
+
resetPositions() {
|
15
|
+
if (this.restoreTimeout) {
|
16
|
+
clearTimeout(this.restoreTimeout);
|
17
|
+
this.restoreTimeout = null;
|
18
|
+
}
|
19
|
+
// Restore the positions after a timeout to allow the DOM to update
|
20
|
+
// and avoid flickering
|
21
|
+
this.restoreTimeout = setTimeout(() => {
|
22
|
+
const offset = 58;
|
23
|
+
const positions = this.defaultSuggestions().map((suggestion) => [suggestion, suggestion.getPosition()]);
|
24
|
+
positions.sort((one, two) => one[1] - two[1]);
|
25
|
+
for (let i = 0; i < positions.length - 1; i++) { // eslint-disable-line
|
26
|
+
if (positions[i + 1][1] < positions[i][1] + offset) {
|
27
|
+
positions[i + 1][1] += offset - (positions[i + 1][1] - positions[i][1]);
|
28
|
+
}
|
29
|
+
}
|
30
|
+
positions.forEach(([suggestion, position]) => suggestion.setPosition(position));
|
31
|
+
}, 100);
|
32
|
+
return this;
|
33
|
+
}
|
34
|
+
|
35
|
+
// restore changes for any suggestion affecting the specified nodes
|
36
|
+
restore(nodes, except = []) {
|
37
|
+
this.suggestions.forEach((suggestion) => {
|
38
|
+
if (!except.includes(suggestion) && suggestion.nodes.some((node) => nodes.includes(node))) {
|
39
|
+
suggestion.restore();
|
40
|
+
}
|
41
|
+
});
|
42
|
+
}
|
43
|
+
|
44
|
+
// The applied suggestion for each boxWrapper or the first suggestion if none is applied
|
45
|
+
defaultSuggestions() {
|
46
|
+
let suggestions = {};
|
47
|
+
this.suggestions.forEach((suggestion) => {
|
48
|
+
if (suggestion.applied) {
|
49
|
+
suggestions[suggestion.boxWrapper.id] = suggestion;
|
50
|
+
} else if (!suggestions[suggestion.boxWrapper.id] && suggestion.isFirst) {
|
51
|
+
suggestions[suggestion.boxWrapper.id] = suggestion;
|
52
|
+
}
|
53
|
+
});
|
54
|
+
return Object.values(suggestions);
|
55
|
+
}
|
56
|
+
|
57
|
+
getApplied() {
|
58
|
+
return this.suggestions.filter((suggestion) => suggestion.applied);
|
59
|
+
}
|
60
|
+
|
61
|
+
getPending() {
|
62
|
+
return this.suggestions.filter((suggestion) => !suggestion.applied);
|
63
|
+
}
|
64
|
+
|
65
|
+
// destroy all suggestions
|
66
|
+
destroy() {
|
67
|
+
this.suggestions.forEach((suggestion) => suggestion.destroy());
|
68
|
+
this.suggestions = [];
|
69
|
+
return this;
|
70
|
+
}
|
71
|
+
|
72
|
+
_fetchSuggestions() {
|
73
|
+
fetch(this.doc.dataset.collaborativeTextsSuggestionsUrl, {
|
74
|
+
headers: {
|
75
|
+
"Content-Type": "application/json",
|
76
|
+
"X-Requested-With": "XMLHttpRequest"
|
77
|
+
}
|
78
|
+
}).
|
79
|
+
then((response) => response.json()).
|
80
|
+
then((data) => {
|
81
|
+
data.forEach((item) => {
|
82
|
+
let suggestion = new Suggestion(this, item)
|
83
|
+
if (suggestion.valid) {
|
84
|
+
this.suggestions.push(suggestion);
|
85
|
+
}
|
86
|
+
});
|
87
|
+
this.resetPositions();
|
88
|
+
});
|
89
|
+
}
|
90
|
+
|
91
|
+
_bindEvents() {
|
92
|
+
this.doc.addEventListener("collaborative-texts:applied", this._onSuggestionApplied.bind(this));
|
93
|
+
this.doc.addEventListener("collaborative-texts:restored", this._onSuggestionRestored.bind(this));
|
94
|
+
}
|
95
|
+
|
96
|
+
_onSuggestionApplied() {
|
97
|
+
this.resetPositions();
|
98
|
+
}
|
99
|
+
|
100
|
+
_onSuggestionRestored() {
|
101
|
+
this.resetPositions();
|
102
|
+
}
|
103
|
+
}
|
@@ -0,0 +1,83 @@
|
|
1
|
+
/* global global, jest */
|
2
|
+
|
3
|
+
import Document from "src/decidim/collaborative_texts/document";
|
4
|
+
import SuggestionsList from "src/decidim/collaborative_texts/suggestions_list";
|
5
|
+
|
6
|
+
describe("Document", () => {
|
7
|
+
let i18n = {test: "test"};
|
8
|
+
let suggestionsUrl = "http://example.com/suggestions";
|
9
|
+
let rolloutUrl = "http://example.com/rollout";
|
10
|
+
|
11
|
+
const content = `
|
12
|
+
<body>
|
13
|
+
<div class="collaborative-texts-alert hidden">
|
14
|
+
<div></div>
|
15
|
+
</div>
|
16
|
+
|
17
|
+
<div data-collaborative-texts-document="true"
|
18
|
+
data-collaborative-texts-i18n='${JSON.stringify(i18n)}'
|
19
|
+
data-collaborative-texts-suggestions-url="${suggestionsUrl}"
|
20
|
+
data-collaborative-texts-rollout-url="${rolloutUrl}">
|
21
|
+
<h2>This is a collaborative text</h2>
|
22
|
+
<p>Some content</p>
|
23
|
+
<h2>This is another title</h2>
|
24
|
+
<ul><li>More content</li><li>Event more content</li></ul>
|
25
|
+
</div>
|
26
|
+
</body>
|
27
|
+
`;
|
28
|
+
|
29
|
+
let doc = null;
|
30
|
+
let fetchResult = [];
|
31
|
+
|
32
|
+
global.fetch = jest.fn(() =>
|
33
|
+
Promise.resolve({
|
34
|
+
json: () => Promise.resolve(fetchResult)
|
35
|
+
})
|
36
|
+
);
|
37
|
+
|
38
|
+
beforeEach(() => {
|
39
|
+
document.body.innerHTML = content;
|
40
|
+
doc = new Document(document.querySelector("[data-collaborative-texts-document]"));
|
41
|
+
});
|
42
|
+
|
43
|
+
it("Filters text nodes and adds ids", () => {
|
44
|
+
expect(doc.fetchSuggestions().suggestionsList).toBeInstanceOf(SuggestionsList);
|
45
|
+
expect(doc.fetchSuggestions().suggestionsList.document).toBe(doc);
|
46
|
+
expect(doc.nodes.length).toBe(4);
|
47
|
+
expect(doc.nodes[0].id).toBe("ct-node-1");
|
48
|
+
expect(doc.nodes[0].textContent).toBe("This is a collaborative text");
|
49
|
+
expect(doc.nodes[1].id).toBe("ct-node-2");
|
50
|
+
expect(doc.nodes[1].textContent).toBe("Some content");
|
51
|
+
expect(doc.nodes[2].id).toBe("ct-node-3");
|
52
|
+
expect(doc.nodes[2].textContent).toBe("This is another title");
|
53
|
+
expect(doc.nodes[3].id).toBe("ct-node-4");
|
54
|
+
expect(doc.nodes[3].childNodes[0].textContent).toBe("More content");
|
55
|
+
expect(doc.nodes[3].childNodes[1].textContent).toBe("Event more content");
|
56
|
+
});
|
57
|
+
|
58
|
+
it("Shows the alert", () => {
|
59
|
+
doc.alert("This is a test");
|
60
|
+
expect(doc.alertWrapper.classList.contains("hidden")).toBe(false);
|
61
|
+
expect(doc.alertDiv.textContent).toBe("This is a test");
|
62
|
+
});
|
63
|
+
|
64
|
+
it("enables suggestions", () => {
|
65
|
+
expect(doc.active).toBe(true);
|
66
|
+
doc.enableSuggestions();
|
67
|
+
window.document.dispatchEvent(new Event("selectstart"));
|
68
|
+
expect(doc.selecting).toBe(true);
|
69
|
+
window.document.dispatchEvent(new Event("mouseup"));
|
70
|
+
expect(doc.selecting).toBe(false);
|
71
|
+
});
|
72
|
+
|
73
|
+
describe("when disabled", () => {
|
74
|
+
beforeEach(() => {
|
75
|
+
document.body.innerHTML = '<div class="collaborative-texts-alert"></div><div data-collaborative-texts-document="false" data-collaborative-texts-i18n="{}"></div>';
|
76
|
+
doc = new Document(document.querySelector("[data-collaborative-texts-document]"));
|
77
|
+
});
|
78
|
+
|
79
|
+
it("disables suggestions", () => {
|
80
|
+
expect(doc.active).toBe(false);
|
81
|
+
});
|
82
|
+
});
|
83
|
+
});
|