katalyst-content 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +95 -0
- data/app/assets/config/katalyst-content.js +1 -0
- data/app/assets/javascripts/controllers/content/editor/container_controller.js +113 -0
- data/app/assets/javascripts/controllers/content/editor/item_controller.js +45 -0
- data/app/assets/javascripts/controllers/content/editor/list_controller.js +105 -0
- data/app/assets/javascripts/controllers/content/editor/new_item_controller.js +12 -0
- data/app/assets/javascripts/controllers/content/editor/status_bar_controller.js +22 -0
- data/app/assets/javascripts/utils/content/editor/container.js +52 -0
- data/app/assets/javascripts/utils/content/editor/item.js +245 -0
- data/app/assets/javascripts/utils/content/editor/rules-engine.js +177 -0
- data/app/assets/stylesheets/katalyst/content/_index.scss +31 -0
- data/app/assets/stylesheets/katalyst/content/editor/_icon.scss +17 -0
- data/app/assets/stylesheets/katalyst/content/editor/_index.scss +145 -0
- data/app/assets/stylesheets/katalyst/content/editor/_item-actions.scss +93 -0
- data/app/assets/stylesheets/katalyst/content/editor/_item-rules.scss +19 -0
- data/app/assets/stylesheets/katalyst/content/editor/_new-items.scss +39 -0
- data/app/assets/stylesheets/katalyst/content/editor/_status-bar.scss +87 -0
- data/app/controllers/katalyst/content/application_controller.rb +8 -0
- data/app/controllers/katalyst/content/items_controller.rb +70 -0
- data/app/helpers/katalyst/content/application_helper.rb +8 -0
- data/app/helpers/katalyst/content/editor/base.rb +44 -0
- data/app/helpers/katalyst/content/editor/container.rb +41 -0
- data/app/helpers/katalyst/content/editor/item.rb +67 -0
- data/app/helpers/katalyst/content/editor/list.rb +41 -0
- data/app/helpers/katalyst/content/editor/new_item.rb +53 -0
- data/app/helpers/katalyst/content/editor/status_bar.rb +57 -0
- data/app/helpers/katalyst/content/editor_helper.rb +42 -0
- data/app/models/concerns/katalyst/content/container.rb +100 -0
- data/app/models/concerns/katalyst/content/garbage_collection.rb +31 -0
- data/app/models/concerns/katalyst/content/has_tree.rb +63 -0
- data/app/models/concerns/katalyst/content/version.rb +33 -0
- data/app/models/katalyst/content/content.rb +21 -0
- data/app/models/katalyst/content/item.rb +36 -0
- data/app/models/katalyst/content/node.rb +21 -0
- data/app/models/katalyst/content/types/nodes_type.rb +42 -0
- data/app/views/active_storage/blobs/_blob.html.erb +14 -0
- data/app/views/katalyst/content/contents/_content.html+form.erb +39 -0
- data/app/views/katalyst/content/contents/_content.html.erb +5 -0
- data/app/views/katalyst/content/editor/_item.html.erb +11 -0
- data/app/views/katalyst/content/editor/_list_item.html.erb +14 -0
- data/app/views/katalyst/content/editor/_new_item.html.erb +3 -0
- data/app/views/katalyst/content/editor/_new_items.html.erb +5 -0
- data/app/views/katalyst/content/items/_item.html+form.erb +34 -0
- data/app/views/katalyst/content/items/_item.html.erb +3 -0
- data/app/views/katalyst/content/items/edit.html.erb +4 -0
- data/app/views/katalyst/content/items/new.html.erb +4 -0
- data/app/views/katalyst/content/items/update.turbo_stream.erb +7 -0
- data/app/views/layouts/action_text/contents/_content.html.erb +3 -0
- data/config/importmap.rb +8 -0
- data/config/locales/en.yml +12 -0
- data/config/routes.rb +3 -0
- data/db/migrate/20220913003839_create_katalyst_content_items.rb +17 -0
- data/lib/katalyst/content/config.rb +18 -0
- data/lib/katalyst/content/engine.rb +36 -0
- data/lib/katalyst/content/version.rb +7 -0
- data/lib/katalyst/content.rb +19 -0
- data/lib/tasks/yarn.rake +18 -0
- data/spec/factories/katalyst/content/items.rb +16 -0
- metadata +103 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: dabae0594160eb1ebafdd3501b5d521113fdcc8bde6f36eede4296314297255d
|
4
|
+
data.tar.gz: aace7d35cd049a4c3fcf140f21099d5d04d03020a1935402e8999fa82c368823
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 452af88132002902aef2edfa6858e772a4985ea069cc6b1726c4acd9f3f2594fb84bf2e35a3377d233c5cfe1411caa5c7fc86c795b52d6c648d3a9c80bf616fe
|
7
|
+
data.tar.gz: 1077d2f70a588105436e553b186f4cd3239a250ef36c2f10e15827973b8ea8375ca62b9b5c227d432f8642ef63a8ccdde832a6c89c482df6b99c79749811e78f
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2022 Katalyst Interactive
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,95 @@
|
|
1
|
+
# Katalyst::Content
|
2
|
+
|
3
|
+
Katalyst Content provides tools for creating and publishing content on Rails
|
4
|
+
applications.
|
5
|
+
|
6
|
+
## Installation
|
7
|
+
|
8
|
+
Add this line to your application's Gemfile:
|
9
|
+
|
10
|
+
```ruby
|
11
|
+
gem 'katalyst-content'
|
12
|
+
```
|
13
|
+
|
14
|
+
And then execute:
|
15
|
+
|
16
|
+
$ bundle install
|
17
|
+
|
18
|
+
Or install it yourself as:
|
19
|
+
|
20
|
+
$ gem install katalyst-content
|
21
|
+
|
22
|
+
## Usage
|
23
|
+
|
24
|
+
Content can be added to multiple models in your application. These examples
|
25
|
+
assume a `Page` model.
|
26
|
+
|
27
|
+
Assuming your model already exists, create a table for versions and add
|
28
|
+
published and draft version columns to your model. For example, if you have a
|
29
|
+
pages model:
|
30
|
+
|
31
|
+
```ruby
|
32
|
+
class CreatePageVersions < ActiveRecord::Migration[7.0]
|
33
|
+
def change
|
34
|
+
create_table :page_versions do |t|
|
35
|
+
t.references :parent, foreign_key: { to_table: :pages }, null: false
|
36
|
+
t.json :nodes
|
37
|
+
|
38
|
+
t.timestamps
|
39
|
+
end
|
40
|
+
|
41
|
+
change_table :pages do |t|
|
42
|
+
t.references :published_version, foreign_key: { to_table: :page_versions }
|
43
|
+
t.references :draft_version, foreign_key: { to_table: :page_versions }
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
```
|
48
|
+
|
49
|
+
Next, include the `Katalyst::Content` concerns into your model, and add a nested
|
50
|
+
model for storing content version information:
|
51
|
+
|
52
|
+
```ruby
|
53
|
+
class Page < ApplicationRecord
|
54
|
+
include Katalyst::Content::Container
|
55
|
+
|
56
|
+
class Version < ApplicationRecord
|
57
|
+
include Katalyst::Content::Version
|
58
|
+
end
|
59
|
+
end
|
60
|
+
```
|
61
|
+
|
62
|
+
You may also want to configure your factory to add container information to
|
63
|
+
items:
|
64
|
+
|
65
|
+
```ruby
|
66
|
+
FactoryBot.define do
|
67
|
+
factory :page do
|
68
|
+
title { Faker::Beer.unique.name }
|
69
|
+
slug { title.parameterize }
|
70
|
+
|
71
|
+
after(:build) do |page, _context|
|
72
|
+
page.items.each { |item| item.container = page }
|
73
|
+
end
|
74
|
+
|
75
|
+
after(:create) do |page, _context|
|
76
|
+
page.items_attributes = page.items.map.with_index { |item, index| { id: item.id, index: index, depth: 0 } }
|
77
|
+
page.publish!
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
```
|
82
|
+
|
83
|
+
## Development
|
84
|
+
|
85
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake` to run the tests.
|
86
|
+
|
87
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
88
|
+
|
89
|
+
## Contributing
|
90
|
+
|
91
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/katalyst/content.
|
92
|
+
|
93
|
+
## License
|
94
|
+
|
95
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
@@ -0,0 +1 @@
|
|
1
|
+
//= link_tree ../javascripts
|
@@ -0,0 +1,113 @@
|
|
1
|
+
import { Controller } from "@hotwired/stimulus";
|
2
|
+
|
3
|
+
import Item from "utils/content/editor/item";
|
4
|
+
import Container from "utils/content/editor/container";
|
5
|
+
import RulesEngine from "utils/content/editor/rules-engine";
|
6
|
+
|
7
|
+
export default class ContainerController extends Controller {
|
8
|
+
static targets = ["container"];
|
9
|
+
static values = {
|
10
|
+
maxDepth: Number,
|
11
|
+
};
|
12
|
+
|
13
|
+
connect() {
|
14
|
+
this.state = this.container.state;
|
15
|
+
|
16
|
+
this.reindex();
|
17
|
+
}
|
18
|
+
|
19
|
+
get container() {
|
20
|
+
return new Container(this.containerTarget);
|
21
|
+
}
|
22
|
+
|
23
|
+
reindex() {
|
24
|
+
this.container.reindex();
|
25
|
+
this.#update();
|
26
|
+
}
|
27
|
+
|
28
|
+
reset() {
|
29
|
+
this.container.reset();
|
30
|
+
}
|
31
|
+
|
32
|
+
remove(event) {
|
33
|
+
const item = getEventItem(event);
|
34
|
+
|
35
|
+
item.node.remove();
|
36
|
+
|
37
|
+
this.#update();
|
38
|
+
event.preventDefault();
|
39
|
+
}
|
40
|
+
|
41
|
+
nest(event) {
|
42
|
+
const item = getEventItem(event);
|
43
|
+
|
44
|
+
item.traverse((child) => {
|
45
|
+
child.depth += 1;
|
46
|
+
});
|
47
|
+
|
48
|
+
this.#update();
|
49
|
+
event.preventDefault();
|
50
|
+
}
|
51
|
+
|
52
|
+
deNest(event) {
|
53
|
+
const item = getEventItem(event);
|
54
|
+
|
55
|
+
item.traverse((child) => {
|
56
|
+
child.depth -= 1;
|
57
|
+
});
|
58
|
+
|
59
|
+
this.#update();
|
60
|
+
event.preventDefault();
|
61
|
+
}
|
62
|
+
|
63
|
+
collapse(event) {
|
64
|
+
const item = getEventItem(event);
|
65
|
+
|
66
|
+
item.collapse();
|
67
|
+
|
68
|
+
this.#update();
|
69
|
+
event.preventDefault();
|
70
|
+
}
|
71
|
+
|
72
|
+
expand(event) {
|
73
|
+
const item = getEventItem(event);
|
74
|
+
|
75
|
+
item.expand();
|
76
|
+
|
77
|
+
this.#update();
|
78
|
+
event.preventDefault();
|
79
|
+
}
|
80
|
+
|
81
|
+
/**
|
82
|
+
* Re-apply rules to items to enable/disable appropriate actions.
|
83
|
+
*/
|
84
|
+
#update() {
|
85
|
+
// debounce requests to ensure that we only update once per tick
|
86
|
+
this.updateRequested = true;
|
87
|
+
setTimeout(() => {
|
88
|
+
if (!this.updateRequested) return;
|
89
|
+
|
90
|
+
this.updateRequested = false;
|
91
|
+
const engine = new RulesEngine(this.maxDepthValue);
|
92
|
+
this.container.items.forEach((item) => engine.update(item));
|
93
|
+
|
94
|
+
this.#notifyChange();
|
95
|
+
}, 0);
|
96
|
+
}
|
97
|
+
|
98
|
+
#notifyChange() {
|
99
|
+
this.dispatch("change", {
|
100
|
+
bubbles: true,
|
101
|
+
prefix: "content",
|
102
|
+
detail: { dirty: this.#isDirty() },
|
103
|
+
});
|
104
|
+
}
|
105
|
+
|
106
|
+
#isDirty() {
|
107
|
+
return this.container.state !== this.state;
|
108
|
+
}
|
109
|
+
}
|
110
|
+
|
111
|
+
function getEventItem(event) {
|
112
|
+
return new Item(event.target.closest("[data-content-item]"));
|
113
|
+
}
|
@@ -0,0 +1,45 @@
|
|
1
|
+
import { Controller } from "@hotwired/stimulus";
|
2
|
+
import Item from "utils/content/editor/item";
|
3
|
+
|
4
|
+
export default class ItemController extends Controller {
|
5
|
+
get item() {
|
6
|
+
return new Item(this.li);
|
7
|
+
}
|
8
|
+
|
9
|
+
get ol() {
|
10
|
+
return this.element.closest("ol");
|
11
|
+
}
|
12
|
+
|
13
|
+
get li() {
|
14
|
+
return this.element.closest("li");
|
15
|
+
}
|
16
|
+
|
17
|
+
connect() {
|
18
|
+
if (this.element.dataset.hasOwnProperty("delete")) {
|
19
|
+
this.remove();
|
20
|
+
}
|
21
|
+
// if index is not already set, re-index will set it
|
22
|
+
else if (!(this.item.index >= 0)) {
|
23
|
+
this.reindex();
|
24
|
+
}
|
25
|
+
// if item has been replaced via turbo, re-index will run the rules engine
|
26
|
+
// update our depth and index with values from the li's data attributes
|
27
|
+
else if (this.item.hasItemIdChanged()) {
|
28
|
+
this.item.updateAfterChange();
|
29
|
+
this.reindex();
|
30
|
+
}
|
31
|
+
}
|
32
|
+
|
33
|
+
remove() {
|
34
|
+
// capture ol
|
35
|
+
const ol = this.ol;
|
36
|
+
// remove self from dom
|
37
|
+
this.li.remove();
|
38
|
+
// reindex ol
|
39
|
+
this.reindex();
|
40
|
+
}
|
41
|
+
|
42
|
+
reindex() {
|
43
|
+
this.dispatch("reindex", { bubbles: true, prefix: "content" });
|
44
|
+
}
|
45
|
+
}
|
@@ -0,0 +1,105 @@
|
|
1
|
+
import { Controller } from "@hotwired/stimulus";
|
2
|
+
|
3
|
+
export default class ListController extends Controller {
|
4
|
+
dragstart(event) {
|
5
|
+
if (this.element !== event.target.parentElement) return;
|
6
|
+
|
7
|
+
const target = event.target;
|
8
|
+
event.dataTransfer.effectAllowed = "move";
|
9
|
+
|
10
|
+
// update element style after drag has begun
|
11
|
+
setTimeout(() => (target.dataset.dragging = ""));
|
12
|
+
}
|
13
|
+
|
14
|
+
dragover(event) {
|
15
|
+
const item = this.dragItem();
|
16
|
+
if (!item) return;
|
17
|
+
|
18
|
+
swap(this.dropTarget(event.target), item);
|
19
|
+
|
20
|
+
event.preventDefault();
|
21
|
+
return true;
|
22
|
+
}
|
23
|
+
|
24
|
+
dragenter(event) {
|
25
|
+
event.preventDefault();
|
26
|
+
|
27
|
+
if (event.dataTransfer.effectAllowed === "copy" && !this.dragItem()) {
|
28
|
+
const item = document.createElement("li");
|
29
|
+
item.dataset.dragging = "";
|
30
|
+
item.dataset.newItem = "";
|
31
|
+
this.element.prepend(item);
|
32
|
+
}
|
33
|
+
}
|
34
|
+
|
35
|
+
dragleave(event) {
|
36
|
+
const item = this.dragItem();
|
37
|
+
const related = this.dropTarget(event.relatedTarget);
|
38
|
+
|
39
|
+
// ignore if item is not set or we're moving into a valid drop target
|
40
|
+
if (!item || related) return;
|
41
|
+
|
42
|
+
// remove item if it's a new item
|
43
|
+
if (item.dataset.hasOwnProperty("newItem")) {
|
44
|
+
item.remove();
|
45
|
+
}
|
46
|
+
}
|
47
|
+
|
48
|
+
drop(event) {
|
49
|
+
const item = this.dragItem();
|
50
|
+
|
51
|
+
if (!item) return;
|
52
|
+
|
53
|
+
event.preventDefault();
|
54
|
+
delete item.dataset.dragging;
|
55
|
+
swap(this.dropTarget(event.target), item);
|
56
|
+
|
57
|
+
if (item.dataset.hasOwnProperty("newItem")) {
|
58
|
+
const template = document.createElement("template");
|
59
|
+
template.innerHTML = event.dataTransfer.getData("text/html");
|
60
|
+
const newItem = template.content.querySelector("li");
|
61
|
+
|
62
|
+
this.element.replaceChild(newItem, item);
|
63
|
+
setTimeout(() =>
|
64
|
+
newItem.querySelector("[role='button'][value='edit']").click()
|
65
|
+
);
|
66
|
+
}
|
67
|
+
|
68
|
+
this.reindex();
|
69
|
+
}
|
70
|
+
|
71
|
+
dragend() {
|
72
|
+
const item = this.dragItem();
|
73
|
+
if (!item) return;
|
74
|
+
|
75
|
+
delete item.dataset.dragging;
|
76
|
+
this.reset();
|
77
|
+
}
|
78
|
+
|
79
|
+
dragItem() {
|
80
|
+
return this.element.querySelector("[data-dragging]");
|
81
|
+
}
|
82
|
+
|
83
|
+
dropTarget(e) {
|
84
|
+
return e && e.closest("[data-controller='content--editor--list'] > *");
|
85
|
+
}
|
86
|
+
|
87
|
+
reindex() {
|
88
|
+
this.dispatch("reindex", { bubbles: true, prefix: "content" });
|
89
|
+
}
|
90
|
+
|
91
|
+
reset() {
|
92
|
+
this.dispatch("reset", { bubbles: true, prefix: "content" });
|
93
|
+
}
|
94
|
+
}
|
95
|
+
|
96
|
+
function swap(target, item) {
|
97
|
+
if (target && target !== item) {
|
98
|
+
const positionComparison = target.compareDocumentPosition(item);
|
99
|
+
if (positionComparison & Node.DOCUMENT_POSITION_FOLLOWING) {
|
100
|
+
target.insertAdjacentElement("beforebegin", item);
|
101
|
+
} else if (positionComparison & Node.DOCUMENT_POSITION_PRECEDING) {
|
102
|
+
target.insertAdjacentElement("afterend", item);
|
103
|
+
}
|
104
|
+
}
|
105
|
+
}
|
@@ -0,0 +1,12 @@
|
|
1
|
+
import { Controller } from "@hotwired/stimulus";
|
2
|
+
|
3
|
+
export default class NewItemController extends Controller {
|
4
|
+
static targets = ["template"];
|
5
|
+
|
6
|
+
dragstart(event) {
|
7
|
+
if (this.element !== event.target) return;
|
8
|
+
|
9
|
+
event.dataTransfer.setData("text/html", this.templateTarget.innerHTML);
|
10
|
+
event.dataTransfer.effectAllowed = "copy";
|
11
|
+
}
|
12
|
+
}
|
@@ -0,0 +1,22 @@
|
|
1
|
+
import { Controller } from "@hotwired/stimulus";
|
2
|
+
|
3
|
+
export default class StatusBarController extends Controller {
|
4
|
+
connect() {
|
5
|
+
// cache the version's state in the controller on connect
|
6
|
+
this.versionState = this.element.dataset.state;
|
7
|
+
}
|
8
|
+
|
9
|
+
change(e) {
|
10
|
+
if (e.detail && e.detail.hasOwnProperty("dirty")) {
|
11
|
+
this.update(e.detail);
|
12
|
+
}
|
13
|
+
}
|
14
|
+
|
15
|
+
update({ dirty }) {
|
16
|
+
if (dirty) {
|
17
|
+
this.element.dataset.state = "dirty";
|
18
|
+
} else {
|
19
|
+
this.element.dataset.state = this.versionState;
|
20
|
+
}
|
21
|
+
}
|
22
|
+
}
|
@@ -0,0 +1,52 @@
|
|
1
|
+
import Item from "utils/content/editor/item";
|
2
|
+
|
3
|
+
/**
|
4
|
+
* @param nodes {NodeList}
|
5
|
+
* @returns {Item[]}
|
6
|
+
*/
|
7
|
+
function createItemList(nodes) {
|
8
|
+
return Array.from(nodes).map((node) => new Item(node));
|
9
|
+
}
|
10
|
+
|
11
|
+
export default class Container {
|
12
|
+
/**
|
13
|
+
* @param node {Element} content editor list
|
14
|
+
*/
|
15
|
+
constructor(node) {
|
16
|
+
this.node = node;
|
17
|
+
}
|
18
|
+
|
19
|
+
/**
|
20
|
+
* @return {Item[]} an ordered list of all items in the container
|
21
|
+
*/
|
22
|
+
get items() {
|
23
|
+
return createItemList(this.node.querySelectorAll("[data-content-index]"));
|
24
|
+
}
|
25
|
+
|
26
|
+
/**
|
27
|
+
* @return {String} a serialized description of the structure of the container
|
28
|
+
*/
|
29
|
+
get state() {
|
30
|
+
const inputs = this.node.querySelectorAll("li input[type=hidden]");
|
31
|
+
return Array.from(inputs)
|
32
|
+
.map((e) => e.value)
|
33
|
+
.join("/");
|
34
|
+
}
|
35
|
+
|
36
|
+
/**
|
37
|
+
* Set the index of items based on their current position.
|
38
|
+
*/
|
39
|
+
reindex() {
|
40
|
+
this.items.map((item, index) => (item.index = index));
|
41
|
+
}
|
42
|
+
|
43
|
+
/**
|
44
|
+
* Resets the order of items to their defined index.
|
45
|
+
* Useful after an aborted drag.
|
46
|
+
*/
|
47
|
+
reset() {
|
48
|
+
this.items.sort(Item.comparator).forEach((item) => {
|
49
|
+
this.node.appendChild(item.node);
|
50
|
+
});
|
51
|
+
}
|
52
|
+
}
|