katalyst-content 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (61) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +95 -0
  4. data/app/assets/config/katalyst-content.js +1 -0
  5. data/app/assets/javascripts/controllers/content/editor/container_controller.js +113 -0
  6. data/app/assets/javascripts/controllers/content/editor/item_controller.js +45 -0
  7. data/app/assets/javascripts/controllers/content/editor/list_controller.js +105 -0
  8. data/app/assets/javascripts/controllers/content/editor/new_item_controller.js +12 -0
  9. data/app/assets/javascripts/controllers/content/editor/status_bar_controller.js +22 -0
  10. data/app/assets/javascripts/utils/content/editor/container.js +52 -0
  11. data/app/assets/javascripts/utils/content/editor/item.js +245 -0
  12. data/app/assets/javascripts/utils/content/editor/rules-engine.js +177 -0
  13. data/app/assets/stylesheets/katalyst/content/_index.scss +31 -0
  14. data/app/assets/stylesheets/katalyst/content/editor/_icon.scss +17 -0
  15. data/app/assets/stylesheets/katalyst/content/editor/_index.scss +145 -0
  16. data/app/assets/stylesheets/katalyst/content/editor/_item-actions.scss +93 -0
  17. data/app/assets/stylesheets/katalyst/content/editor/_item-rules.scss +19 -0
  18. data/app/assets/stylesheets/katalyst/content/editor/_new-items.scss +39 -0
  19. data/app/assets/stylesheets/katalyst/content/editor/_status-bar.scss +87 -0
  20. data/app/controllers/katalyst/content/application_controller.rb +8 -0
  21. data/app/controllers/katalyst/content/items_controller.rb +70 -0
  22. data/app/helpers/katalyst/content/application_helper.rb +8 -0
  23. data/app/helpers/katalyst/content/editor/base.rb +44 -0
  24. data/app/helpers/katalyst/content/editor/container.rb +41 -0
  25. data/app/helpers/katalyst/content/editor/item.rb +67 -0
  26. data/app/helpers/katalyst/content/editor/list.rb +41 -0
  27. data/app/helpers/katalyst/content/editor/new_item.rb +53 -0
  28. data/app/helpers/katalyst/content/editor/status_bar.rb +57 -0
  29. data/app/helpers/katalyst/content/editor_helper.rb +42 -0
  30. data/app/models/concerns/katalyst/content/container.rb +100 -0
  31. data/app/models/concerns/katalyst/content/garbage_collection.rb +31 -0
  32. data/app/models/concerns/katalyst/content/has_tree.rb +63 -0
  33. data/app/models/concerns/katalyst/content/version.rb +33 -0
  34. data/app/models/katalyst/content/content.rb +21 -0
  35. data/app/models/katalyst/content/item.rb +36 -0
  36. data/app/models/katalyst/content/node.rb +21 -0
  37. data/app/models/katalyst/content/types/nodes_type.rb +42 -0
  38. data/app/views/active_storage/blobs/_blob.html.erb +14 -0
  39. data/app/views/katalyst/content/contents/_content.html+form.erb +39 -0
  40. data/app/views/katalyst/content/contents/_content.html.erb +5 -0
  41. data/app/views/katalyst/content/editor/_item.html.erb +11 -0
  42. data/app/views/katalyst/content/editor/_list_item.html.erb +14 -0
  43. data/app/views/katalyst/content/editor/_new_item.html.erb +3 -0
  44. data/app/views/katalyst/content/editor/_new_items.html.erb +5 -0
  45. data/app/views/katalyst/content/items/_item.html+form.erb +34 -0
  46. data/app/views/katalyst/content/items/_item.html.erb +3 -0
  47. data/app/views/katalyst/content/items/edit.html.erb +4 -0
  48. data/app/views/katalyst/content/items/new.html.erb +4 -0
  49. data/app/views/katalyst/content/items/update.turbo_stream.erb +7 -0
  50. data/app/views/layouts/action_text/contents/_content.html.erb +3 -0
  51. data/config/importmap.rb +8 -0
  52. data/config/locales/en.yml +12 -0
  53. data/config/routes.rb +3 -0
  54. data/db/migrate/20220913003839_create_katalyst_content_items.rb +17 -0
  55. data/lib/katalyst/content/config.rb +18 -0
  56. data/lib/katalyst/content/engine.rb +36 -0
  57. data/lib/katalyst/content/version.rb +7 -0
  58. data/lib/katalyst/content.rb +19 -0
  59. data/lib/tasks/yarn.rake +18 -0
  60. data/spec/factories/katalyst/content/items.rb +16 -0
  61. metadata +103 -0
@@ -0,0 +1,245 @@
1
+ export default class Item {
2
+ /**
3
+ * Sort items by their index.
4
+ *
5
+ * @param a {Item}
6
+ * @param b {Item}
7
+ * @returns {number}
8
+ */
9
+ static comparator(a, b) {
10
+ return a.index - b.index;
11
+ }
12
+
13
+ /**
14
+ * @param node {Element} li[data-content-index]
15
+ */
16
+ constructor(node) {
17
+ this.node = node;
18
+ }
19
+
20
+ /**
21
+ * @returns {String} id of the node's item (from data attributes)
22
+ */
23
+ get itemId() {
24
+ return this.node.dataset[`contentItemId`];
25
+ }
26
+
27
+ get #itemIdInput() {
28
+ return this.node.querySelector(`input[name$="[id]"]`);
29
+ }
30
+
31
+ /**
32
+ * @param itemId {String} id
33
+ */
34
+ set itemId(id) {
35
+ if (this.itemId === id) return;
36
+
37
+ this.node.dataset[`contentItemId`] = `${id}`;
38
+ this.#itemIdInput.value = `${id}`;
39
+ }
40
+
41
+ /**
42
+ * @returns {number} logical nesting depth of node in container
43
+ */
44
+ get depth() {
45
+ return parseInt(this.node.dataset[`contentDepth`]);
46
+ }
47
+
48
+ get #depthInput() {
49
+ return this.node.querySelector(`input[name$="[depth]"]`);
50
+ }
51
+
52
+ /**
53
+ * @param depth {number} depth >= 0
54
+ */
55
+ set depth(depth) {
56
+ if (this.depth === depth) return;
57
+
58
+ this.node.dataset[`contentDepth`] = `${depth}`;
59
+ this.#depthInput.value = `${depth}`;
60
+ }
61
+
62
+ /**
63
+ * @returns {number} logical index of node in container (pre-order traversal)
64
+ */
65
+ get index() {
66
+ return parseInt(this.node.dataset[`contentIndex`]);
67
+ }
68
+
69
+ get #indexInput() {
70
+ return this.node.querySelector(`input[name$="[index]"]`);
71
+ }
72
+
73
+ /**
74
+ * @param index {number} index >= 0
75
+ */
76
+ set index(index) {
77
+ if (this.index === index) return;
78
+
79
+ this.node.dataset[`contentIndex`] = `${index}`;
80
+ this.#indexInput.value = `${index}`;
81
+ }
82
+
83
+ /**
84
+ * @returns {Item} nearest neighbour (index - 1)
85
+ */
86
+ get previousItem() {
87
+ let sibling = this.node.previousElementSibling;
88
+ if (sibling) return new Item(sibling);
89
+ }
90
+
91
+ /**
92
+ * @returns {Item} nearest neighbour (index + 1)
93
+ */
94
+ get nextItem() {
95
+ let sibling = this.node.nextElementSibling;
96
+ if (sibling) return new Item(sibling);
97
+ }
98
+
99
+ /**
100
+ * @returns {boolean} true if this item has any collapsed children
101
+ */
102
+ hasCollapsedDescendants() {
103
+ let childrenList = this.#childrenListElement;
104
+ return !!childrenList && childrenList.children.length > 0;
105
+ }
106
+
107
+ /**
108
+ * @returns {boolean} true if this item has any expanded children
109
+ */
110
+ hasExpandedDescendants() {
111
+ let sibling = this.nextItem;
112
+ return !!sibling && sibling.depth > this.depth;
113
+ }
114
+
115
+ /**
116
+ * Recursively traverse the node and its descendants.
117
+ *
118
+ * @callback {Item}
119
+ */
120
+ traverse(callback) {
121
+ // capture descendants before traversal in case of side-effects
122
+ // specifically, setting depth affects calculation
123
+ const collapsed = this.#collapsedChildren;
124
+ const expanded = this.#expandedDescendants;
125
+
126
+ callback(this);
127
+ collapsed.forEach((item) => item.traverse(callback));
128
+ expanded.forEach((item) => item.traverse(callback));
129
+ }
130
+
131
+ /**
132
+ * Collapses visible (logical) children into this element's hidden children
133
+ * list, creating it if it doesn't already exist.
134
+ */
135
+ collapse() {
136
+ let listElement = this.#childrenListElement;
137
+
138
+ if (!listElement) listElement = createChildrenList(this.node);
139
+
140
+ this.#expandedDescendants.forEach((child) =>
141
+ listElement.appendChild(child.node)
142
+ );
143
+ }
144
+
145
+ /**
146
+ * Moves any collapsed children back into the parent container.
147
+ */
148
+ expand() {
149
+ if (!this.hasCollapsedDescendants()) return;
150
+
151
+ Array.from(this.#childrenListElement.children)
152
+ .reverse()
153
+ .forEach((node) => {
154
+ this.node.insertAdjacentElement("afterend", node);
155
+ });
156
+ }
157
+
158
+ /**
159
+ * Sets the state of a given rule on the target node.
160
+ *
161
+ * @param rule {String}
162
+ * @param deny {boolean}
163
+ */
164
+ toggleRule(rule, deny = false) {
165
+ if (this.node.dataset.hasOwnProperty(rule) && !deny)
166
+ delete this.node.dataset[rule];
167
+ if (!this.node.dataset.hasOwnProperty(rule) && deny)
168
+ this.node.dataset[rule] = "";
169
+
170
+ if (rule === "denyDrag") {
171
+ if (!this.node.hasAttribute("draggable") && !deny) {
172
+ this.node.setAttribute("draggable", "true");
173
+ }
174
+ if (this.node.hasAttribute("draggable") && deny) {
175
+ this.node.removeAttribute("draggable");
176
+ }
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Detects turbo item changes by comparing the dataset id with the input
182
+ */
183
+ hasItemIdChanged() {
184
+ return !(this.#itemIdInput.value === this.itemId);
185
+ }
186
+
187
+ /**
188
+ * Updates inputs, in case they don't match the data values, e.g., when the
189
+ * nested inputs have been hot-swapped by turbo with data from the server.
190
+ *
191
+ * Updates itemId from input as that is the canonical source.
192
+ */
193
+ updateAfterChange() {
194
+ this.itemId = this.#itemIdInput.value;
195
+ this.#indexInput.value = this.index;
196
+ this.#depthInput.value = this.depth;
197
+ }
198
+
199
+ /**
200
+ * Finds the dom container for storing collapsed (hidden) children, if present.
201
+ *
202
+ * @returns {Element} ol[data-content-children]
203
+ */
204
+ get #childrenListElement() {
205
+ return this.node.querySelector(`:scope > [data-content-children]`);
206
+ }
207
+
208
+ get #expandedDescendants() {
209
+ const descendants = [];
210
+
211
+ let sibling = this.nextItem;
212
+ while (sibling && sibling.depth > this.depth) {
213
+ descendants.push(sibling);
214
+ sibling = sibling.nextItem;
215
+ }
216
+
217
+ return descendants;
218
+ }
219
+
220
+ get #collapsedChildren() {
221
+ if (!this.hasCollapsedDescendants()) return [];
222
+
223
+ return Array.from(this.#childrenListElement.children).map(
224
+ (node) => new Item(node)
225
+ );
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Finds or creates a dom container for storing collapsed (hidden) children.
231
+ *
232
+ * @param node {Element} li[data-content-index]
233
+ * @returns {Element} ol[data-content-children]
234
+ */
235
+ function createChildrenList(node) {
236
+ const childrenList = document.createElement("ol");
237
+ childrenList.setAttribute("class", "hidden");
238
+
239
+ // if objectType is "rich-content" set richContentChildren as a data attribute
240
+ childrenList.dataset[`contentChildren`] = "";
241
+
242
+ node.appendChild(childrenList);
243
+
244
+ return childrenList;
245
+ }
@@ -0,0 +1,177 @@
1
+ export default class RulesEngine {
2
+ static rules = [
3
+ "denyDeNest",
4
+ "denyNest",
5
+ "denyCollapse",
6
+ "denyExpand",
7
+ "denyRemove",
8
+ "denyDrag",
9
+ "denyEdit",
10
+ ];
11
+
12
+ constructor(maxDepth = null) {
13
+ this.maxDepth = maxDepth;
14
+ }
15
+
16
+ /**
17
+ * Apply rules to the given item by computing a ruleset then merging it
18
+ * with the item's current state.
19
+ *
20
+ * @param {Item} item
21
+ */
22
+ update(item) {
23
+ this.rules = {};
24
+
25
+ // structural rules enforce a valid tree structure
26
+ this.firstItemDepthZero(item);
27
+ this.depthMustBeSet(item);
28
+ this.itemCannotHaveInvalidDepth(item);
29
+ this.itemCannotExceedDepthLimit(item);
30
+
31
+ // behavioural rules define what the user is allowed to do
32
+ this.parentsCannotDeNest(item);
33
+ this.rootsCannotDeNest(item);
34
+ this.nestingNeedsParent(item);
35
+ this.nestingCannotExceedMaxDepth(item);
36
+ this.leavesCannotCollapse(item);
37
+ this.needHiddenItemsToExpand(item);
38
+ this.parentsCannotBeDeleted(item);
39
+ this.parentsCannotBeDragged(item);
40
+
41
+ RulesEngine.rules.forEach((rule) => {
42
+ item.toggleRule(rule, !!this.rules[rule]);
43
+ });
44
+ }
45
+
46
+ /**
47
+ * First item can't have a parent, so its depth should always be 0
48
+ */
49
+ firstItemDepthZero(item) {
50
+ if (item.index === 0) {
51
+ item.depth = 0;
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Every item should have a non-negative depth set.
57
+ *
58
+ * @param {Item} item
59
+ */
60
+ depthMustBeSet(item) {
61
+ if (isNaN(item.depth) || item.depth < 0) {
62
+ item.depth = 0;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Depth must increase stepwise.
68
+ *
69
+ * @param {Item} item
70
+ */
71
+ itemCannotHaveInvalidDepth(item) {
72
+ const previous = item.previousItem;
73
+ if (previous && previous.depth < item.depth - 1) {
74
+ item.depth = previous.depth + 1;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Depth must not exceed container's depth limit.
80
+ *
81
+ * @param {Item} item
82
+ */
83
+ itemCannotExceedDepthLimit(item) {
84
+ if (this.maxDepth > 0 && this.maxDepth <= item.depth) {
85
+ // Note: this change can cause an issue where the previous item is treated
86
+ // like a parent even though it no longer has children. This is because
87
+ // items are processed in order. This issue does not seem worth solving
88
+ // as it only occurs if the max depth is altered. The issue can be worked
89
+ // around by saving the container.
90
+ item.depth = this.maxDepth - 1;
91
+ }
92
+ }
93
+
94
+ /**
95
+ * De-nesting an item would create a gap of 2 between itself and its children
96
+ *
97
+ * @param {Item} item
98
+ */
99
+ parentsCannotDeNest(item) {
100
+ if (item.hasExpandedDescendants()) this.#deny("denyDeNest");
101
+ }
102
+
103
+ /**
104
+ * Item depth can't go below 0.
105
+ *
106
+ * @param {Item} item
107
+ */
108
+ rootsCannotDeNest(item) {
109
+ if (item.depth === 0) this.#deny("denyDeNest");
110
+ }
111
+
112
+ /**
113
+ * If an item doesn't have children it can't be collapsed.
114
+ *
115
+ * @param {Item} item
116
+ */
117
+ leavesCannotCollapse(item) {
118
+ if (!item.hasExpandedDescendants()) this.#deny("denyCollapse");
119
+ }
120
+
121
+ /**
122
+ * If an item doesn't have any hidden descendants then it can't be expanded.
123
+ *
124
+ * @param {Item} item
125
+ */
126
+ needHiddenItemsToExpand(item) {
127
+ if (!item.hasCollapsedDescendants()) this.#deny("denyExpand");
128
+ }
129
+
130
+ /**
131
+ * An item can't be nested (indented) if it doesn't have a valid parent.
132
+ *
133
+ * @param {Item} item
134
+ */
135
+ nestingNeedsParent(item) {
136
+ const previous = item.previousItem;
137
+ if (!previous || previous.depth < item.depth) this.#deny("denyNest");
138
+ }
139
+
140
+ /**
141
+ * An item can't be nested (indented) if doing so would exceed the max depth.
142
+ *
143
+ * @param {Item} item
144
+ */
145
+ nestingCannotExceedMaxDepth(item) {
146
+ if (this.maxDepth > 0 && this.maxDepth <= item.depth + 1) {
147
+ this.#deny("denyNest");
148
+ }
149
+ }
150
+
151
+ /**
152
+ * An item can't be deleted if it has visible children.
153
+ *
154
+ * @param {Item} item
155
+ */
156
+ parentsCannotBeDeleted(item) {
157
+ if (item.hasExpandedDescendants()) this.#deny("denyRemove");
158
+ }
159
+
160
+ /**
161
+ * Items cannot be dragged if they have visible children.
162
+ *
163
+ * @param {Item} item
164
+ */
165
+ parentsCannotBeDragged(item) {
166
+ if (item.hasExpandedDescendants()) this.#deny("denyDrag");
167
+ }
168
+
169
+ /**
170
+ * Record a deny.
171
+ *
172
+ * @param rule {String}
173
+ */
174
+ #deny(rule) {
175
+ this.rules[rule] = true;
176
+ }
177
+ }
@@ -0,0 +1,31 @@
1
+ @use "editor";
2
+ @use "trix";
3
+
4
+ /*
5
+ * We need to override trix.css’s image gallery styles to accommodate the
6
+ * <action-text-attachment> element we wrap around attachments. Otherwise,
7
+ * images in galleries will be squished by the max-width: 33%; rule.
8
+ */
9
+ .trix-content .attachment-gallery > action-text-attachment,
10
+ .trix-content .attachment-gallery > .attachment {
11
+ flex: 1 0 33%;
12
+ padding: 0 0.5em;
13
+ max-width: 33%;
14
+ }
15
+
16
+ .trix-content
17
+ .attachment-gallery.attachment-gallery--2
18
+ > action-text-attachment,
19
+ .trix-content .attachment-gallery.attachment-gallery--2 > .attachment,
20
+ .trix-content
21
+ .attachment-gallery.attachment-gallery--4
22
+ > action-text-attachment,
23
+ .trix-content .attachment-gallery.attachment-gallery--4 > .attachment {
24
+ flex-basis: 50%;
25
+ max-width: 50%;
26
+ }
27
+
28
+ .trix-content action-text-attachment .attachment {
29
+ padding: 0 !important;
30
+ max-width: 100% !important;
31
+ }
@@ -0,0 +1,17 @@
1
+ %icon-block {
2
+ display: block;
3
+ cursor: pointer;
4
+ position: relative;
5
+ padding: 0.65rem;
6
+ min-width: 2.5rem;
7
+ min-height: 2.5rem;
8
+ }
9
+
10
+ %icon {
11
+ position: absolute;
12
+ content: "";
13
+ width: 1.2rem;
14
+ height: 1.2rem;
15
+ background-repeat: no-repeat;
16
+ background-position: center;
17
+ }
@@ -0,0 +1,145 @@
1
+ @use "icon";
2
+
3
+ @use "item-actions";
4
+ @use "item-rules";
5
+ @use "new-items";
6
+ @use "status-bar";
7
+
8
+ $grey-light: #f4f4f4 !default;
9
+ $grey: #ececec !default;
10
+ $grey-dark: #999 !default;
11
+ $table-hover-background: #fff0eb !default;
12
+ $primary-color: #ff521f !default;
13
+
14
+ $row-inset: 2rem !default;
15
+ $row-height: 3rem !default;
16
+
17
+ $table-header-color: $grey !default;
18
+ $row-background-color: $grey-light !default;
19
+ $row-hover-color: $table-hover-background !default;
20
+ $icon-active-color: $primary-color !default;
21
+ $icon-passive-color: $grey-dark !default;
22
+
23
+ $status-published-background-color: #ebf9eb !default;
24
+ $status-published-border-color: #4dd45c !default;
25
+ $status-published-color: #4dd45c !default;
26
+
27
+ $status-draft-background-color: #fefaf3 !default;
28
+ $status-draft-border-color: #ffa800 !default;
29
+ $status-draft-color: #ffa800 !default;
30
+
31
+ $status-dirty-background-color: #eee !default;
32
+ $status-dirty-border-color: #888 !default;
33
+ $status-dirty-color: #aaa !default;
34
+
35
+ [data-controller="content--editor--container"] {
36
+ --row-height: #{$row-height};
37
+ --row-inset: #{$row-inset};
38
+ --table-header-color: #{$table-header-color};
39
+ --row-background-color: #{$row-background-color};
40
+ --row-hover-color: #{$row-hover-color};
41
+ --icon-active-color: #{$icon-active-color};
42
+ --icon-passive-color: #{$icon-passive-color};
43
+
44
+ ol,
45
+ li {
46
+ margin: 0;
47
+ padding: 0;
48
+ padding-inline-start: 0;
49
+ list-style: none;
50
+ }
51
+
52
+ .hidden {
53
+ display: none !important;
54
+ }
55
+ }
56
+
57
+ [data-controller="content--editor--list"] {
58
+ min-height: var(--row-height);
59
+
60
+ // tree items
61
+ & > li {
62
+ display: block;
63
+ min-height: var(--row-height);
64
+
65
+ // https://github.com/react-dnd/react-dnd/issues/832
66
+ transform: translate3d(0, 0, 0);
67
+
68
+ // Pinstripe effect
69
+ &:nth-of-type(even) {
70
+ background: var(--row-background-color);
71
+ }
72
+
73
+ &:hover {
74
+ background: var(--row-hover-color);
75
+ }
76
+
77
+ &[draggable] {
78
+ cursor: grab;
79
+ }
80
+
81
+ // Dragged visuals
82
+ &[data-dragging] {
83
+ box-shadow: inset 0 0 0 2px var(--icon-passive-color);
84
+
85
+ > * {
86
+ visibility: hidden;
87
+ }
88
+ }
89
+
90
+ // Depth spacing
91
+ @for $i from 1 through 6 {
92
+ &[data-content-depth="#{$i}"] .tree {
93
+ padding-left: calc(var(--row-inset) * #{$i});
94
+ }
95
+ }
96
+
97
+ .tree {
98
+ display: flex;
99
+ align-items: center;
100
+ }
101
+
102
+ .title,
103
+ .url {
104
+ text-overflow: ellipsis;
105
+ overflow: hidden;
106
+ white-space: nowrap;
107
+ }
108
+ }
109
+ }
110
+
111
+ [data-controller="content--editor--container"] [role="rowheader"],
112
+ [data-controller="content--editor--item"] {
113
+ display: grid;
114
+ grid-template-columns: 40% 2fr auto;
115
+ padding: 0.25rem 0.5rem;
116
+ gap: 1rem;
117
+ align-items: center;
118
+ }
119
+
120
+ // Ensures vertical alignment of header with rows
121
+ [data-controller="content--editor-container"] {
122
+ [role="rowheader"] {
123
+ min-height: var(--row-height);
124
+ background: var(--table-header-color);
125
+ padding-inline: 1.25rem 1rem;
126
+ }
127
+ }
128
+
129
+ [data-controller="content--editor--status-bar"] {
130
+ --background: #{$status-published-background-color};
131
+ --color: #{$status-published-border-color};
132
+ --border: #{$status-published-color};
133
+
134
+ &[data-state="draft"] {
135
+ --background: #{$status-draft-background-color};
136
+ --color: #{$status-draft-border-color};
137
+ --border: #{$status-draft-color};
138
+ }
139
+
140
+ &[data-state="dirty"] {
141
+ --background: #{$status-dirty-background-color};
142
+ --color: #{$status-dirty-border-color};
143
+ --border: #{$status-dirty-color};
144
+ }
145
+ }