katalyst-content 1.1.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +81 -7
  3. data/app/assets/builds/katalyst/content.esm.js +1004 -0
  4. data/app/assets/builds/katalyst/content.js +1004 -0
  5. data/app/assets/builds/katalyst/content.min.js +2 -0
  6. data/app/assets/builds/katalyst/content.min.js.map +1 -0
  7. data/app/assets/config/katalyst-content.js +1 -1
  8. data/app/assets/stylesheets/katalyst/content/editor/_index.scss +1 -1
  9. data/app/assets/stylesheets/katalyst/content/editor/_new-items.scss +36 -10
  10. data/app/assets/stylesheets/katalyst/content/editor/_status-bar.scss +3 -1
  11. data/app/{helpers/katalyst/content/editor/base.rb → components/katalyst/content/editor/base_component.rb} +15 -12
  12. data/app/components/katalyst/content/editor/errors_component.html.erb +12 -0
  13. data/app/components/katalyst/content/editor/errors_component.rb +15 -0
  14. data/app/components/katalyst/content/editor/item_component.html.erb +25 -0
  15. data/app/components/katalyst/content/editor/item_component.rb +28 -0
  16. data/app/components/katalyst/content/editor/new_item_component.html.erb +22 -0
  17. data/app/components/katalyst/content/editor/new_item_component.rb +52 -0
  18. data/app/{views/katalyst/content/editor/_new_items.html.erb → components/katalyst/content/editor/new_items_component.html.erb} +1 -1
  19. data/app/components/katalyst/content/editor/new_items_component.rb +20 -0
  20. data/app/{views/katalyst/content/editor/_list_item.html.erb → components/katalyst/content/editor/row_component.html.erb} +1 -1
  21. data/app/components/katalyst/content/editor/row_component.rb +13 -0
  22. data/app/{helpers/katalyst/content/editor/status_bar.rb → components/katalyst/content/editor/status_bar_component.rb} +17 -13
  23. data/app/components/katalyst/content/editor/table_component.html.erb +11 -0
  24. data/app/components/katalyst/content/editor/table_component.rb +36 -0
  25. data/app/components/katalyst/content/editor_component.html.erb +9 -0
  26. data/app/components/katalyst/content/editor_component.rb +49 -0
  27. data/app/controllers/katalyst/content/items_controller.rb +6 -3
  28. data/app/helpers/katalyst/content/editor_helper.rb +4 -48
  29. data/app/helpers/katalyst/content/frontend_helper.rb +3 -3
  30. data/app/javascript/content/application.js +35 -0
  31. data/app/{assets/javascripts/utils → javascript}/content/editor/container.js +1 -1
  32. data/app/{assets/javascripts/controllers → javascript}/content/editor/container_controller.js +3 -3
  33. data/app/{assets/javascripts/controllers → javascript}/content/editor/item_controller.js +1 -1
  34. data/app/{assets/javascripts/controllers → javascript}/content/editor/list_controller.js +12 -2
  35. data/app/models/katalyst/content/types/nodes_type.rb +2 -2
  36. data/app/views/active_storage/blobs/_blob.html.erb +1 -1
  37. data/app/views/katalyst/content/asides/_aside.html+form.erb +2 -2
  38. data/app/views/katalyst/content/columns/_column.html+form.erb +2 -2
  39. data/app/views/katalyst/content/contents/_content.html+form.erb +3 -3
  40. data/app/views/katalyst/content/figures/_figure.html+form.erb +2 -2
  41. data/app/views/katalyst/content/groups/_group.html+form.erb +2 -2
  42. data/app/views/katalyst/content/items/_item.html+form.erb +2 -2
  43. data/app/views/katalyst/content/items/update.turbo_stream.erb +2 -2
  44. data/app/views/katalyst/content/sections/_section.html+form.erb +2 -2
  45. data/config/importmap.rb +1 -6
  46. data/lib/katalyst/content.rb +0 -1
  47. metadata +61 -24
  48. data/app/helpers/katalyst/content/editor/container.rb +0 -43
  49. data/app/helpers/katalyst/content/editor/errors.rb +0 -24
  50. data/app/helpers/katalyst/content/editor/item.rb +0 -67
  51. data/app/helpers/katalyst/content/editor/list.rb +0 -41
  52. data/app/helpers/katalyst/content/editor/new_item.rb +0 -53
  53. data/app/views/katalyst/content/editor/_item.html.erb +0 -14
  54. data/app/views/katalyst/content/editor/_new_item.html.erb +0 -3
  55. data/lib/katalyst/content/version.rb +0 -7
  56. /data/app/{assets/javascripts/utils → javascript}/content/editor/item.js +0 -0
  57. /data/app/{assets/javascripts/controllers → javascript}/content/editor/new_item_controller.js +0 -0
  58. /data/app/{assets/javascripts/utils/content/editor/rules-engine.js → javascript/content/editor/rules_engine.js} +0 -0
  59. /data/app/{assets/javascripts/controllers → javascript}/content/editor/status_bar_controller.js +0 -0
  60. /data/app/{assets/javascripts/controllers → javascript}/content/editor/trix_controller.js +0 -0
@@ -0,0 +1,1004 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+ import 'trix';
3
+
4
+ class Item {
5
+ /**
6
+ * Sort items by their index.
7
+ *
8
+ * @param a {Item}
9
+ * @param b {Item}
10
+ * @returns {number}
11
+ */
12
+ static comparator(a, b) {
13
+ return a.index - b.index;
14
+ }
15
+
16
+ /**
17
+ * @param node {Element} li[data-content-index]
18
+ */
19
+ constructor(node) {
20
+ this.node = node;
21
+ }
22
+
23
+ /**
24
+ * @returns {String} id of the node's item (from data attributes)
25
+ */
26
+ get itemId() {
27
+ return this.node.dataset[`contentItemId`];
28
+ }
29
+
30
+ get #itemIdInput() {
31
+ return this.node.querySelector(`input[name$="[id]"]`);
32
+ }
33
+
34
+ /**
35
+ * @param itemId {String} id
36
+ */
37
+ set itemId(id) {
38
+ if (this.itemId === id) return;
39
+
40
+ this.node.dataset[`contentItemId`] = `${id}`;
41
+ this.#itemIdInput.value = `${id}`;
42
+ }
43
+
44
+ /**
45
+ * @returns {number} logical nesting depth of node in container
46
+ */
47
+ get depth() {
48
+ return parseInt(this.node.dataset[`contentDepth`]) || 0;
49
+ }
50
+
51
+ get #depthInput() {
52
+ return this.node.querySelector(`input[name$="[depth]"]`);
53
+ }
54
+
55
+ /**
56
+ * @param depth {number} depth >= 0
57
+ */
58
+ set depth(depth) {
59
+ if (this.depth === depth) return;
60
+
61
+ this.node.dataset[`contentDepth`] = `${depth}`;
62
+ this.#depthInput.value = `${depth}`;
63
+ }
64
+
65
+ /**
66
+ * @returns {number} logical index of node in container (pre-order traversal)
67
+ */
68
+ get index() {
69
+ return parseInt(this.node.dataset[`contentIndex`]);
70
+ }
71
+
72
+ get #indexInput() {
73
+ return this.node.querySelector(`input[name$="[index]"]`);
74
+ }
75
+
76
+ /**
77
+ * @param index {number} index >= 0
78
+ */
79
+ set index(index) {
80
+ if (this.index === index) return;
81
+
82
+ this.node.dataset[`contentIndex`] = `${index}`;
83
+ this.#indexInput.value = `${index}`;
84
+ }
85
+
86
+ /**
87
+ * @returns {boolean} true if this item can have children
88
+ */
89
+ get isLayout() {
90
+ return this.node.hasAttribute("data-content-layout");
91
+ }
92
+
93
+ /**
94
+ * @returns {Item} nearest neighbour (index - 1)
95
+ */
96
+ get previousItem() {
97
+ let sibling = this.node.previousElementSibling;
98
+ if (sibling) return new Item(sibling);
99
+ }
100
+
101
+ /**
102
+ * @returns {Item} nearest neighbour (index + 1)
103
+ */
104
+ get nextItem() {
105
+ let sibling = this.node.nextElementSibling;
106
+ if (sibling) return new Item(sibling);
107
+ }
108
+
109
+ /**
110
+ * @returns {boolean} true if this item has any collapsed children
111
+ */
112
+ hasCollapsedDescendants() {
113
+ let childrenList = this.#childrenListElement;
114
+ return !!childrenList && childrenList.children.length > 0;
115
+ }
116
+
117
+ /**
118
+ * @returns {boolean} true if this item has any expanded children
119
+ */
120
+ hasExpandedDescendants() {
121
+ let sibling = this.nextItem;
122
+ return !!sibling && sibling.depth > this.depth;
123
+ }
124
+
125
+ /**
126
+ * Recursively traverse the node and its descendants.
127
+ *
128
+ * @callback {Item}
129
+ */
130
+ traverse(callback) {
131
+ // capture descendants before traversal in case of side-effects
132
+ // specifically, setting depth affects calculation
133
+ const expanded = this.#expandedDescendants;
134
+
135
+ callback(this);
136
+ this.#traverseCollapsed(callback);
137
+ expanded.forEach((item) => item.#traverseCollapsed(callback));
138
+ }
139
+
140
+ /**
141
+ * Recursively traverse the node's collapsed descendants, if any.
142
+ *
143
+ * @callback {Item}
144
+ */
145
+ #traverseCollapsed(callback) {
146
+ if (!this.hasCollapsedDescendants()) return;
147
+
148
+ this.#collapsedDescendants.forEach((item) => {
149
+ callback(item);
150
+ item.#traverseCollapsed(callback);
151
+ });
152
+ }
153
+
154
+ /**
155
+ * Move the given item into this element's hidden children list.
156
+ * Assumes the list already exists.
157
+ *
158
+ * @param item {Item}
159
+ */
160
+ collapseChild(item) {
161
+ this.#childrenListElement.appendChild(item.node);
162
+ }
163
+
164
+ /**
165
+ * Collapses visible (logical) children into this element's hidden children
166
+ * list, creating it if it doesn't already exist.
167
+ */
168
+ collapse() {
169
+ let listElement = this.#childrenListElement;
170
+
171
+ if (!listElement) listElement = createChildrenList(this.node);
172
+
173
+ this.#expandedDescendants.forEach((child) =>
174
+ listElement.appendChild(child.node)
175
+ );
176
+ }
177
+
178
+ /**
179
+ * Moves any collapsed children back into the parent container.
180
+ */
181
+ expand() {
182
+ if (!this.hasCollapsedDescendants()) return;
183
+
184
+ Array.from(this.#childrenListElement.children)
185
+ .reverse()
186
+ .forEach((node) => {
187
+ this.node.insertAdjacentElement("afterend", node);
188
+ });
189
+ }
190
+
191
+ /**
192
+ * Sets the state of a given rule on the target node.
193
+ *
194
+ * @param rule {String}
195
+ * @param deny {boolean}
196
+ */
197
+ toggleRule(rule, deny = false) {
198
+ if (this.node.dataset.hasOwnProperty(rule) && !deny) {
199
+ delete this.node.dataset[rule];
200
+ }
201
+ if (!this.node.dataset.hasOwnProperty(rule) && deny) {
202
+ this.node.dataset[rule] = "";
203
+ }
204
+
205
+ if (rule === "denyDrag") {
206
+ if (!this.node.hasAttribute("draggable") && !deny) {
207
+ this.node.setAttribute("draggable", "true");
208
+ }
209
+ if (this.node.hasAttribute("draggable") && deny) {
210
+ this.node.removeAttribute("draggable");
211
+ }
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Detects turbo item changes by comparing the dataset id with the input
217
+ */
218
+ hasItemIdChanged() {
219
+ return !(this.#itemIdInput.value === this.itemId);
220
+ }
221
+
222
+ /**
223
+ * Updates inputs, in case they don't match the data values, e.g., when the
224
+ * nested inputs have been hot-swapped by turbo with data from the server.
225
+ *
226
+ * Updates itemId from input as that is the canonical source.
227
+ */
228
+ updateAfterChange() {
229
+ this.itemId = this.#itemIdInput.value;
230
+ this.#indexInput.value = this.index;
231
+ this.#depthInput.value = this.depth;
232
+ }
233
+
234
+ /**
235
+ * Finds the dom container for storing collapsed (hidden) children, if present.
236
+ *
237
+ * @returns {Element} ol[data-content-children]
238
+ */
239
+ get #childrenListElement() {
240
+ return this.node.querySelector(`:scope > [data-content-children]`);
241
+ }
242
+
243
+ /**
244
+ * @returns {Item[]} all items that follow this element that have a greater depth.
245
+ */
246
+ get #expandedDescendants() {
247
+ const descendants = [];
248
+
249
+ let sibling = this.nextItem;
250
+ while (sibling && sibling.depth > this.depth) {
251
+ descendants.push(sibling);
252
+ sibling = sibling.nextItem;
253
+ }
254
+
255
+ return descendants;
256
+ }
257
+
258
+ /**
259
+ * @returns {Item[]} all items directly contained inside this element's hidden children element.
260
+ */
261
+ get #collapsedDescendants() {
262
+ if (!this.hasCollapsedDescendants()) return [];
263
+
264
+ return Array.from(this.#childrenListElement.children).map(
265
+ (node) => new Item(node)
266
+ );
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Finds or creates a dom container for storing collapsed (hidden) children.
272
+ *
273
+ * @param node {Element} li[data-content-index]
274
+ * @returns {Element} ol[data-content-children]
275
+ */
276
+ function createChildrenList(node) {
277
+ const childrenList = document.createElement("ol");
278
+ childrenList.setAttribute("class", "hidden");
279
+
280
+ // if objectType is "rich-content" set richContentChildren as a data attribute
281
+ childrenList.dataset[`contentChildren`] = "";
282
+
283
+ node.appendChild(childrenList);
284
+
285
+ return childrenList;
286
+ }
287
+
288
+ /**
289
+ * @param nodes {NodeList}
290
+ * @returns {Item[]}
291
+ */
292
+ function createItemList(nodes) {
293
+ return Array.from(nodes).map((node) => new Item(node));
294
+ }
295
+
296
+ class Container {
297
+ /**
298
+ * @param node {Element} content editor list
299
+ */
300
+ constructor(node) {
301
+ this.node = node;
302
+ }
303
+
304
+ /**
305
+ * @return {Item[]} an ordered list of all items in the container
306
+ */
307
+ get items() {
308
+ return createItemList(this.node.querySelectorAll("[data-content-index]"));
309
+ }
310
+
311
+ /**
312
+ * @return {String} a serialized description of the structure of the container
313
+ */
314
+ get state() {
315
+ const inputs = this.node.querySelectorAll("li input[type=hidden]");
316
+ return Array.from(inputs)
317
+ .map((e) => e.value)
318
+ .join("/");
319
+ }
320
+
321
+ /**
322
+ * Set the index of items based on their current position.
323
+ */
324
+ reindex() {
325
+ this.items.map((item, index) => (item.index = index));
326
+ }
327
+
328
+ /**
329
+ * Resets the order of items to their defined index.
330
+ * Useful after an aborted drag.
331
+ */
332
+ reset() {
333
+ this.items.sort(Item.comparator).forEach((item) => {
334
+ this.node.appendChild(item.node);
335
+ });
336
+ }
337
+ }
338
+
339
+ class RulesEngine {
340
+ static rules = [
341
+ "denyDeNest",
342
+ "denyNest",
343
+ "denyCollapse",
344
+ "denyExpand",
345
+ "denyRemove",
346
+ "denyDrag",
347
+ "denyEdit",
348
+ ];
349
+
350
+ constructor(debug = false) {
351
+ if (debug) {
352
+ this.debug = (...args) => console.log(...args);
353
+ } else {
354
+ this.debug = () => {};
355
+ }
356
+ }
357
+
358
+ /**
359
+ * Enforce structural rules to ensure that the given item is currently in a
360
+ * valid state.
361
+ *
362
+ * @param {Item} item
363
+ */
364
+ normalize(item) {
365
+ // structural rules enforce a valid tree structure
366
+ this.firstItemDepthZero(item);
367
+ this.depthMustBeSet(item);
368
+ this.itemCannotHaveInvalidDepth(item);
369
+ this.parentMustBeLayout(item);
370
+ this.parentCannotHaveExpandedAndCollapsedChildren(item);
371
+ }
372
+
373
+ /**
374
+ * Apply rules to the given item to determine what operations are permitted.
375
+ *
376
+ * @param {Item} item
377
+ */
378
+ update(item) {
379
+ this.rules = {};
380
+
381
+ // behavioural rules define what the user is allowed to do
382
+ this.parentsCannotDeNest(item);
383
+ this.rootsCannotDeNest(item);
384
+ this.onlyLastItemCanDeNest(item);
385
+ this.nestingNeedsParent(item);
386
+ this.leavesCannotCollapse(item);
387
+ this.needHiddenItemsToExpand(item);
388
+ this.parentsCannotBeDeleted(item);
389
+ this.parentsCannotBeDragged(item);
390
+
391
+ RulesEngine.rules.forEach((rule) => {
392
+ item.toggleRule(rule, !!this.rules[rule]);
393
+ });
394
+ }
395
+
396
+ /**
397
+ * First item can't have a parent, so its depth should always be 0
398
+ */
399
+ firstItemDepthZero(item) {
400
+ if (item.index === 0 && item.depth !== 0) {
401
+ this.debug(`enforce depth on item ${item.index}: ${item.depth} => 0`);
402
+
403
+ item.depth = 0;
404
+ }
405
+ }
406
+
407
+ /**
408
+ * Every item should have a non-negative depth set.
409
+ *
410
+ * @param {Item} item
411
+ */
412
+ depthMustBeSet(item) {
413
+ if (isNaN(item.depth) || item.depth < 0) {
414
+ this.debug(`unset depth on item ${item.index}: => 0`);
415
+
416
+ item.depth = 0;
417
+ }
418
+ }
419
+
420
+ /**
421
+ * Depth must increase stepwise.
422
+ *
423
+ * @param {Item} item
424
+ */
425
+ itemCannotHaveInvalidDepth(item) {
426
+ const previous = item.previousItem;
427
+ if (previous && previous.depth < item.depth - 1) {
428
+ this.debug(
429
+ `invalid depth on item ${item.index}: ${item.depth} => ${
430
+ previous.depth + 1
431
+ }`
432
+ );
433
+
434
+ item.depth = previous.depth + 1;
435
+ }
436
+ }
437
+
438
+ /**
439
+ * Parent item, if any, must be a layout.
440
+ *
441
+ * @param {Item} item
442
+ */
443
+ parentMustBeLayout(item) {
444
+ // if we're the first child, make sure our parent is a layout
445
+ // if we're a sibling, we know the previous item is valid so we must be too
446
+ const previous = item.previousItem;
447
+ if (previous && previous.depth < item.depth && !previous.isLayout) {
448
+ this.debug(
449
+ `invalid parent for item ${item.index}: ${item.depth} => ${previous.depth}`
450
+ );
451
+
452
+ item.depth = previous.depth;
453
+ }
454
+ }
455
+
456
+ /**
457
+ * If a parent has expanded and collapsed children, expand.
458
+ *
459
+ * @param {Item} item
460
+ */
461
+ parentCannotHaveExpandedAndCollapsedChildren(item) {
462
+ if (item.hasCollapsedDescendants() && item.hasExpandedDescendants()) {
463
+ this.debug(`expanding collapsed children of item ${item.index}`);
464
+
465
+ item.expand();
466
+ }
467
+ }
468
+
469
+ /**
470
+ * De-nesting an item would create a gap of 2 between itself and its children
471
+ *
472
+ * @param {Item} item
473
+ */
474
+ parentsCannotDeNest(item) {
475
+ if (item.hasExpandedDescendants()) this.#deny("denyDeNest");
476
+ }
477
+
478
+ /**
479
+ * Item depth can't go below 0.
480
+ *
481
+ * @param {Item} item
482
+ */
483
+ rootsCannotDeNest(item) {
484
+ if (item.depth === 0) this.#deny("denyDeNest");
485
+ }
486
+
487
+ /**
488
+ * De-nesting an item that has siblings would make it a container.
489
+ *
490
+ * @param {Item} item
491
+ */
492
+ onlyLastItemCanDeNest(item) {
493
+ const next = item.nextItem;
494
+ if (next && next.depth === item.depth && !item.isLayout)
495
+ this.#deny("denyDeNest");
496
+ }
497
+
498
+ /**
499
+ * If an item doesn't have children it can't be collapsed.
500
+ *
501
+ * @param {Item} item
502
+ */
503
+ leavesCannotCollapse(item) {
504
+ if (!item.hasExpandedDescendants()) this.#deny("denyCollapse");
505
+ }
506
+
507
+ /**
508
+ * If an item doesn't have any hidden descendants then it can't be expanded.
509
+ *
510
+ * @param {Item} item
511
+ */
512
+ needHiddenItemsToExpand(item) {
513
+ if (!item.hasCollapsedDescendants()) this.#deny("denyExpand");
514
+ }
515
+
516
+ /**
517
+ * An item can't be nested (indented) if it doesn't have a valid parent.
518
+ *
519
+ * @param {Item} item
520
+ */
521
+ nestingNeedsParent(item) {
522
+ const previous = item.previousItem;
523
+ // no previous, so cannot nest
524
+ if (!previous) this.#deny("denyNest");
525
+ // previous is too shallow, nesting would increase depth too much
526
+ else if (previous.depth < item.depth) this.#deny("denyNest");
527
+ // new parent is not a layout
528
+ else if (previous.depth === item.depth && !previous.isLayout)
529
+ this.#deny("denyNest");
530
+ }
531
+
532
+ /**
533
+ * An item can't be deleted if it has visible children.
534
+ *
535
+ * @param {Item} item
536
+ */
537
+ parentsCannotBeDeleted(item) {
538
+ if (!item.itemId || item.hasExpandedDescendants()) this.#deny("denyRemove");
539
+ }
540
+
541
+ /**
542
+ * Items cannot be dragged if they have visible children.
543
+ *
544
+ * @param {Item} item
545
+ */
546
+ parentsCannotBeDragged(item) {
547
+ if (item.hasExpandedDescendants()) this.#deny("denyDrag");
548
+ }
549
+
550
+ /**
551
+ * Record a deny.
552
+ *
553
+ * @param rule {String}
554
+ */
555
+ #deny(rule) {
556
+ this.rules[rule] = true;
557
+ }
558
+ }
559
+
560
+ class ContainerController extends Controller {
561
+ static targets = ["container"];
562
+
563
+ connect() {
564
+ this.state = this.container.state;
565
+
566
+ this.reindex();
567
+ }
568
+
569
+ get container() {
570
+ return new Container(this.containerTarget);
571
+ }
572
+
573
+ reindex() {
574
+ this.container.reindex();
575
+ this.#update();
576
+ }
577
+
578
+ reset() {
579
+ this.container.reset();
580
+ }
581
+
582
+ drop(event) {
583
+ this.container.reindex(); // set indexes before calculating previous
584
+
585
+ const item = getEventItem(event);
586
+ const previous = item.previousItem;
587
+
588
+ let delta = 0;
589
+ if (previous === undefined) {
590
+ // if previous does not exist, set depth to 0
591
+ delta = -item.depth;
592
+ } else if (
593
+ previous.isLayout &&
594
+ item.nextItem &&
595
+ item.nextItem.depth > previous.depth
596
+ ) {
597
+ // if previous is a layout and next is a child of previous, make item a child of previous
598
+ delta = previous.depth - item.depth + 1;
599
+ } else {
600
+ // otherwise, make item a sibling of previous
601
+ delta = previous.depth - item.depth;
602
+ }
603
+
604
+ item.traverse((child) => {
605
+ child.depth += delta;
606
+ });
607
+
608
+ this.#update();
609
+ event.preventDefault();
610
+ }
611
+
612
+ remove(event) {
613
+ const item = getEventItem(event);
614
+
615
+ item.node.remove();
616
+
617
+ this.#update();
618
+ event.preventDefault();
619
+ }
620
+
621
+ nest(event) {
622
+ const item = getEventItem(event);
623
+
624
+ item.traverse((child) => {
625
+ child.depth += 1;
626
+ });
627
+
628
+ this.#update();
629
+ event.preventDefault();
630
+ }
631
+
632
+ deNest(event) {
633
+ const item = getEventItem(event);
634
+
635
+ item.traverse((child) => {
636
+ child.depth -= 1;
637
+ });
638
+
639
+ this.#update();
640
+ event.preventDefault();
641
+ }
642
+
643
+ collapse(event) {
644
+ const item = getEventItem(event);
645
+
646
+ item.collapse();
647
+
648
+ this.#update();
649
+ event.preventDefault();
650
+ }
651
+
652
+ expand(event) {
653
+ const item = getEventItem(event);
654
+
655
+ item.expand();
656
+
657
+ this.#update();
658
+ event.preventDefault();
659
+ }
660
+
661
+ /**
662
+ * Re-apply rules to items to enable/disable appropriate actions.
663
+ */
664
+ #update() {
665
+ // debounce requests to ensure that we only update once per tick
666
+ this.updateRequested = true;
667
+ setTimeout(() => {
668
+ if (!this.updateRequested) return;
669
+
670
+ this.updateRequested = false;
671
+ const engine = new RulesEngine();
672
+ this.container.items.forEach((item) => engine.normalize(item));
673
+ this.container.items.forEach((item) => engine.update(item));
674
+
675
+ this.#notifyChange();
676
+ }, 0);
677
+ }
678
+
679
+ #notifyChange() {
680
+ this.dispatch("change", {
681
+ bubbles: true,
682
+ prefix: "content",
683
+ detail: { dirty: this.#isDirty() },
684
+ });
685
+ }
686
+
687
+ #isDirty() {
688
+ return this.container.state !== this.state;
689
+ }
690
+ }
691
+
692
+ function getEventItem(event) {
693
+ return new Item(event.target.closest("[data-content-item]"));
694
+ }
695
+
696
+ class ItemController extends Controller {
697
+ get item() {
698
+ return new Item(this.li);
699
+ }
700
+
701
+ get ol() {
702
+ return this.element.closest("ol");
703
+ }
704
+
705
+ get li() {
706
+ return this.element.closest("li");
707
+ }
708
+
709
+ connect() {
710
+ if (this.element.dataset.hasOwnProperty("delete")) {
711
+ this.remove();
712
+ }
713
+ // if index is not already set, re-index will set it
714
+ else if (!(this.item.index >= 0)) {
715
+ this.reindex();
716
+ }
717
+ // if item has been replaced via turbo, re-index will run the rules engine
718
+ // update our depth and index with values from the li's data attributes
719
+ else if (this.item.hasItemIdChanged()) {
720
+ this.item.updateAfterChange();
721
+ this.reindex();
722
+ }
723
+ }
724
+
725
+ remove() {
726
+ // capture ol
727
+ this.ol;
728
+ // remove self from dom
729
+ this.li.remove();
730
+ // reindex ol
731
+ this.reindex();
732
+ }
733
+
734
+ reindex() {
735
+ this.dispatch("reindex", { bubbles: true, prefix: "content" });
736
+ }
737
+ }
738
+
739
+ class ListController extends Controller {
740
+ dragstart(event) {
741
+ if (this.element !== event.target.parentElement) return;
742
+
743
+ const target = event.target;
744
+ event.dataTransfer.effectAllowed = "move";
745
+
746
+ // update element style after drag has begun
747
+ setTimeout(() => (target.dataset.dragging = ""));
748
+ }
749
+
750
+ dragover(event) {
751
+ const item = this.dragItem();
752
+ if (!item) return;
753
+
754
+ swap(this.dropTarget(event.target), item);
755
+
756
+ event.preventDefault();
757
+ return true;
758
+ }
759
+
760
+ dragenter(event) {
761
+ event.preventDefault();
762
+
763
+ if (event.dataTransfer.effectAllowed === "copy" && !this.dragItem()) {
764
+ const item = document.createElement("li");
765
+ item.dataset.dragging = "";
766
+ item.dataset.newItem = "";
767
+ this.element.prepend(item);
768
+ }
769
+ }
770
+
771
+ dragleave(event) {
772
+ const item = this.dragItem();
773
+ const related = this.dropTarget(event.relatedTarget);
774
+
775
+ // ignore if item is not set or we're moving into a valid drop target
776
+ if (!item || related) return;
777
+
778
+ // remove item if it's a new item
779
+ if (item.dataset.hasOwnProperty("newItem")) {
780
+ item.remove();
781
+ }
782
+ }
783
+
784
+ drop(event) {
785
+ let item = this.dragItem();
786
+
787
+ if (!item) return;
788
+
789
+ event.preventDefault();
790
+ delete item.dataset.dragging;
791
+ swap(this.dropTarget(event.target), item);
792
+
793
+ if (item.dataset.hasOwnProperty("newItem")) {
794
+ const placeholder = item;
795
+ const template = document.createElement("template");
796
+ template.innerHTML = event.dataTransfer.getData("text/html");
797
+ item = template.content.querySelector("li");
798
+
799
+ this.element.replaceChild(item, placeholder);
800
+ setTimeout(() =>
801
+ item.querySelector("[role='button'][value='edit']").click()
802
+ );
803
+ }
804
+
805
+ this.dispatch("drop", { target: item, bubbles: true, prefix: "content" });
806
+ }
807
+
808
+ dragend() {
809
+ const item = this.dragItem();
810
+ if (!item) return;
811
+
812
+ delete item.dataset.dragging;
813
+ this.reset();
814
+ }
815
+
816
+ dragItem() {
817
+ return this.element.querySelector("[data-dragging]");
818
+ }
819
+
820
+ dropTarget(e) {
821
+ return (
822
+ e.closest("[data-controller='content--editor--list'] > *") ||
823
+ e.closest("[data-controller='content--editor--list']")
824
+ );
825
+ }
826
+
827
+ reindex() {
828
+ this.dispatch("reindex", { bubbles: true, prefix: "content" });
829
+ }
830
+
831
+ reset() {
832
+ this.dispatch("reset", { bubbles: true, prefix: "content" });
833
+ }
834
+ }
835
+
836
+ function swap(target, item) {
837
+ if (!target) return;
838
+ if (target === item) return;
839
+
840
+ if (target.nodeName === "LI") {
841
+ const positionComparison = target.compareDocumentPosition(item);
842
+ if (positionComparison & Node.DOCUMENT_POSITION_FOLLOWING) {
843
+ target.insertAdjacentElement("beforebegin", item);
844
+ } else if (positionComparison & Node.DOCUMENT_POSITION_PRECEDING) {
845
+ target.insertAdjacentElement("afterend", item);
846
+ }
847
+ }
848
+
849
+ if (target.nodeName === "OL") {
850
+ target.appendChild(item);
851
+ }
852
+ }
853
+
854
+ class NewItemController extends Controller {
855
+ static targets = ["template"];
856
+
857
+ dragstart(event) {
858
+ if (this.element !== event.target) return;
859
+
860
+ event.dataTransfer.setData("text/html", this.templateTarget.innerHTML);
861
+ event.dataTransfer.effectAllowed = "copy";
862
+ }
863
+ }
864
+
865
+ class StatusBarController extends Controller {
866
+ connect() {
867
+ // cache the version's state in the controller on connect
868
+ this.versionState = this.element.dataset.state;
869
+ }
870
+
871
+ change(e) {
872
+ if (e.detail && e.detail.hasOwnProperty("dirty")) {
873
+ this.update(e.detail);
874
+ }
875
+ }
876
+
877
+ update({ dirty }) {
878
+ if (dirty) {
879
+ this.element.dataset.state = "dirty";
880
+ } else {
881
+ this.element.dataset.state = this.versionState;
882
+ }
883
+ }
884
+ }
885
+
886
+ // Note, action_text 7.1.2 changes how Trix is bundled and loaded. This
887
+ // seems to have broken the default export from trix. This is a workaround
888
+ // that relies on the backwards compatibility of the old export to window.Trix.
889
+ const Trix = window.Trix;
890
+
891
+ // Stimulus controller doesn't do anything, but having one ensures that trix
892
+ // will be lazy loaded when a trix-editor is added to the dom.
893
+ class TrixController extends Controller {
894
+ trixInitialize(e) {
895
+ // noop, useful as an extension point for registering behaviour on load
896
+ }
897
+ }
898
+
899
+ // Add H4 as an acceptable tag
900
+ Trix.config.blockAttributes["heading4"] = {
901
+ tagName: "h4",
902
+ terminal: true,
903
+ breakOnReturn: true,
904
+ group: false,
905
+ };
906
+
907
+ // Remove H1 from trix list of acceptable tags
908
+ delete Trix.config.blockAttributes.heading1;
909
+
910
+ /**
911
+ * Allow users to enter path and fragment URIs which the input[type=url] browser
912
+ * input does not permit. Uses a permissive regex pattern which is not suitable
913
+ * for untrusted use cases.
914
+ */
915
+ const LINK_PATTERN = "(https?|mailto:|tel:|/|#).*?";
916
+
917
+ /**
918
+ * Customize default toolbar:
919
+ *
920
+ * * headings: h4 instead of h1
921
+ * * links: use type=text instead of type=url
922
+ *
923
+ * @returns {String} toolbar html fragment
924
+ */
925
+ Trix.config.toolbar.getDefaultHTML = () => {
926
+ const { lang } = Trix.config;
927
+ return `
928
+ <div class="trix-button-row">
929
+ <span class="trix-button-group trix-button-group--text-tools" data-trix-button-group="text-tools">
930
+ <button type="button" class="trix-button trix-button--icon trix-button--icon-bold" data-trix-attribute="bold" data-trix-key="b" title="${lang.bold}" tabindex="-1">${lang.bold}</button>
931
+ <button type="button" class="trix-button trix-button--icon trix-button--icon-italic" data-trix-attribute="italic" data-trix-key="i" title="${lang.italic}" tabindex="-1">${lang.italic}</button>
932
+ <button type="button" class="trix-button trix-button--icon trix-button--icon-strike" data-trix-attribute="strike" title="${lang.strike}" tabindex="-1">${lang.strike}</button>
933
+ <button type="button" class="trix-button trix-button--icon trix-button--icon-link" data-trix-attribute="href" data-trix-action="link" data-trix-key="k" title="${lang.link}" tabindex="-1">${lang.link}</button>
934
+ </span>
935
+ <span class="trix-button-group trix-button-group--block-tools" data-trix-button-group="block-tools">
936
+ <button type="button" class="trix-button trix-button--icon trix-button--icon-heading-1" data-trix-attribute="heading4" title="${lang.heading1}" tabindex="-1">${lang.heading1}</button>
937
+ <button type="button" class="trix-button trix-button--icon trix-button--icon-quote" data-trix-attribute="quote" title="${lang.quote}" tabindex="-1">${lang.quote}</button>
938
+ <button type="button" class="trix-button trix-button--icon trix-button--icon-code" data-trix-attribute="code" title="${lang.code}" tabindex="-1">${lang.code}</button>
939
+ <button type="button" class="trix-button trix-button--icon trix-button--icon-bullet-list" data-trix-attribute="bullet" title="${lang.bullets}" tabindex="-1">${lang.bullets}</button>
940
+ <button type="button" class="trix-button trix-button--icon trix-button--icon-number-list" data-trix-attribute="number" title="${lang.numbers}" tabindex="-1">${lang.numbers}</button>
941
+ <button type="button" class="trix-button trix-button--icon trix-button--icon-decrease-nesting-level" data-trix-action="decreaseNestingLevel" title="${lang.outdent}" tabindex="-1">${lang.outdent}</button>
942
+ <button type="button" class="trix-button trix-button--icon trix-button--icon-increase-nesting-level" data-trix-action="increaseNestingLevel" title="${lang.indent}" tabindex="-1">${lang.indent}</button>
943
+ </span>
944
+ <span class="trix-button-group trix-button-group--file-tools" data-trix-button-group="file-tools">
945
+ <button type="button" class="trix-button trix-button--icon trix-button--icon-attach" data-trix-action="attachFiles" title="${lang.attachFiles}" tabindex="-1">${lang.attachFiles}</button>
946
+ </span>
947
+ <span class="trix-button-group-spacer"></span>
948
+ <span class="trix-button-group trix-button-group--history-tools" data-trix-button-group="history-tools">
949
+ <button type="button" class="trix-button trix-button--icon trix-button--icon-undo" data-trix-action="undo" data-trix-key="z" title="${lang.undo}" tabindex="-1">${lang.undo}</button>
950
+ <button type="button" class="trix-button trix-button--icon trix-button--icon-redo" data-trix-action="redo" data-trix-key="shift+z" title="${lang.redo}" tabindex="-1">${lang.redo}</button>
951
+ </span>
952
+ </div>
953
+ <div class="trix-dialogs" data-trix-dialogs>
954
+ <div class="trix-dialog trix-dialog--link" data-trix-dialog="href" data-trix-dialog-attribute="href">
955
+ <div class="trix-dialog__link-fields">
956
+ <input type="text" name="href" pattern="${LINK_PATTERN}" class="trix-input trix-input--dialog" placeholder="${lang.urlPlaceholder}" aria-label="${lang.url}" required data-trix-input>
957
+ <div class="trix-button-group">
958
+ <input type="button" class="trix-button trix-button--dialog" value="${lang.link}" data-trix-method="setAttribute">
959
+ <input type="button" class="trix-button trix-button--dialog" value="${lang.unlink}" data-trix-method="removeAttribute">
960
+ </div>
961
+ </div>
962
+ </div>
963
+ </div>
964
+ `;
965
+ };
966
+
967
+ /**
968
+ * If the <trix-editor> element is in the HTML when Trix loads, then Trix will have already injected the toolbar content
969
+ * before our code gets a chance to run. Fix that now.
970
+ *
971
+ * Note: in Trix 2 this is likely to no longer be necessary.
972
+ */
973
+ document.querySelectorAll("trix-toolbar").forEach((e) => {
974
+ e.innerHTML = Trix.config.toolbar.getDefaultHTML();
975
+ });
976
+
977
+ const Definitions = [
978
+ {
979
+ identifier: "content--editor--container",
980
+ controllerConstructor: ContainerController,
981
+ },
982
+ {
983
+ identifier: "content--editor--item",
984
+ controllerConstructor: ItemController,
985
+ },
986
+ {
987
+ identifier: "content--editor--list",
988
+ controllerConstructor: ListController,
989
+ },
990
+ {
991
+ identifier: "content--editor--new-item",
992
+ controllerConstructor: NewItemController,
993
+ },
994
+ {
995
+ identifier: "content--editor--status-bar",
996
+ controllerConstructor: StatusBarController,
997
+ },
998
+ {
999
+ identifier: "content--editor--trix",
1000
+ controllerConstructor: TrixController,
1001
+ },
1002
+ ];
1003
+
1004
+ export { Definitions as default };