katalyst-content 1.1.1 → 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
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 };