active_canvas 0.0.5 → 0.1.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.
- checksums.yaml +4 -4
- data/README.md +23 -1
- data/app/assets/javascripts/active_canvas/admin/data_panel.js +357 -0
- data/app/assets/javascripts/active_canvas/admin/field_builder.js +84 -0
- data/app/assets/javascripts/active_canvas/admin/media_select_preview.js +11 -0
- data/app/assets/javascripts/active_canvas/editor/ac_bindings.js +81 -0
- data/app/assets/javascripts/active_canvas/editor/ac_chips.js +192 -0
- data/app/assets/javascripts/active_canvas/editor/code_panel.js +10 -7
- data/app/assets/javascripts/active_canvas/editor/directive_traits.js +95 -0
- data/app/assets/javascripts/active_canvas/editor/live_data.js +178 -0
- data/app/assets/javascripts/active_canvas/editor/panels.js +23 -7
- data/app/assets/javascripts/active_canvas/editor/preview_modal.js +112 -0
- data/app/assets/javascripts/active_canvas/editor/template_validator.js +95 -0
- data/app/assets/javascripts/active_canvas/editor.js +21 -3
- data/app/assets/stylesheets/active_canvas/editor.css +626 -0
- data/app/controllers/active_canvas/admin/application_controller.rb +8 -0
- data/app/controllers/active_canvas/admin/collection_items_controller.rb +93 -0
- data/app/controllers/active_canvas/admin/collections_controller.rb +51 -0
- data/app/controllers/active_canvas/admin/form_submissions_controller.rb +51 -0
- data/app/controllers/active_canvas/admin/pages_controller.rb +127 -5
- data/app/controllers/active_canvas/form_submissions_controller.rb +90 -0
- data/app/controllers/active_canvas/pages_controller.rb +8 -0
- data/app/controllers/active_canvas/redirects_controller.rb +10 -0
- data/app/helpers/active_canvas/admin/collection_items_helper.rb +33 -0
- data/app/models/active_canvas/auto_drop.rb +69 -0
- data/app/models/active_canvas/collection.rb +98 -0
- data/app/models/active_canvas/collection_item.rb +66 -0
- data/app/models/active_canvas/collection_item_version.rb +18 -0
- data/app/models/active_canvas/current.rb +2 -0
- data/app/models/active_canvas/form_submission.rb +9 -0
- data/app/models/active_canvas/page.rb +76 -15
- data/app/models/active_canvas/page_redirect.rb +7 -0
- data/app/services/active_canvas/collection_schema.rb +110 -0
- data/app/services/active_canvas/collection_source.rb +81 -0
- data/app/services/active_canvas/content_sanitizer.rb +33 -21
- data/app/services/active_canvas/form_schema.rb +48 -0
- data/app/services/active_canvas/form_stamper.rb +86 -0
- data/app/services/active_canvas/template_renderer/binding_resolver.rb +138 -0
- data/app/services/active_canvas/template_renderer/directive_expander.rb +54 -0
- data/app/services/active_canvas/template_renderer.rb +160 -0
- data/app/views/active_canvas/admin/collection_items/_field_input.html.erb +40 -0
- data/app/views/active_canvas/admin/collection_items/_form.html.erb +28 -0
- data/app/views/active_canvas/admin/collection_items/edit.html.erb +24 -0
- data/app/views/active_canvas/admin/collection_items/history.html.erb +34 -0
- data/app/views/active_canvas/admin/collection_items/index.html.erb +63 -0
- data/app/views/active_canvas/admin/collection_items/new.html.erb +9 -0
- data/app/views/active_canvas/admin/collections/_field_builder.html.erb +35 -0
- data/app/views/active_canvas/admin/collections/_form.html.erb +31 -0
- data/app/views/active_canvas/admin/collections/edit.html.erb +10 -0
- data/app/views/active_canvas/admin/collections/index.html.erb +45 -0
- data/app/views/active_canvas/admin/collections/new.html.erb +9 -0
- data/app/views/active_canvas/admin/form_submissions/index.html.erb +80 -0
- data/app/views/active_canvas/admin/form_submissions/show.html.erb +99 -0
- data/app/views/active_canvas/admin/pages/_data_panel.html.erb +81 -0
- data/app/views/active_canvas/admin/pages/_form.html.erb +10 -0
- data/app/views/active_canvas/admin/pages/editor.html.erb +62 -1
- data/app/views/active_canvas/pages/show.html.erb +4 -1
- data/app/views/layouts/active_canvas/admin/application.html.erb +15 -0
- data/config/routes.rb +20 -0
- data/db/migrate/20260517100001_add_template_enabled_and_bindings_to_active_canvas_pages.rb +6 -0
- data/db/migrate/20260517100002_add_bindings_to_active_canvas_page_versions.rb +6 -0
- data/db/migrate/20260702000001_create_active_canvas_page_redirects.rb +11 -0
- data/db/migrate/20260702000002_create_active_canvas_form_submissions.rb +14 -0
- data/db/migrate/20260702000003_create_active_canvas_collections.rb +12 -0
- data/db/migrate/20260702000004_create_active_canvas_collection_items.rb +16 -0
- data/db/migrate/20260702000005_create_active_canvas_collection_item_versions.rb +17 -0
- data/lib/active_canvas/configuration.rb +32 -1
- data/lib/active_canvas/data_sources/errors.rb +51 -0
- data/lib/active_canvas/data_sources/param_spec.rb +64 -0
- data/lib/active_canvas/data_sources/registration.rb +60 -0
- data/lib/active_canvas/data_sources/source.rb +47 -0
- data/lib/active_canvas/data_sources.rb +54 -0
- data/lib/active_canvas/engine.rb +15 -0
- data/lib/active_canvas/version.rb +1 -1
- data/lib/active_canvas.rb +6 -0
- metadata +85 -3
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ActiveCanvas Editor - Liquid chips
|
|
3
|
+
*
|
|
4
|
+
* The canvas always holds the page SOURCE. On load every `{{ expr }}` found in
|
|
5
|
+
* a text node is wrapped in <span data-ac-var> so it can be styled and locked;
|
|
6
|
+
* before save, preview, validation and the code view the wrapper is removed
|
|
7
|
+
* again. Attributes, <script> and <style> are never touched, so the round
|
|
8
|
+
* trip is lossless by construction. Loops and conditions are attributes
|
|
9
|
+
* (data-ac-for / data-ac-if) that GrapesJS preserves on its own.
|
|
10
|
+
*/
|
|
11
|
+
(function() {
|
|
12
|
+
'use strict';
|
|
13
|
+
|
|
14
|
+
const VAR_RE = /\{\{[\s\S]*?\}\}/g;
|
|
15
|
+
const SKIP_PARENTS = ['SCRIPT', 'STYLE', 'TEXTAREA'];
|
|
16
|
+
let chipCounter = 0;
|
|
17
|
+
|
|
18
|
+
// Chip ids only need to be unique within one editor session; the server
|
|
19
|
+
// keys live values by them and they never reach the saved source.
|
|
20
|
+
function nextId() {
|
|
21
|
+
return `c${++chipCounter}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function sourceOf(el) {
|
|
25
|
+
return el.getAttribute('data-ac-source') || el.textContent;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Live-data ghosts (the other items of a loop) are display only.
|
|
29
|
+
function stripGhosts(doc) {
|
|
30
|
+
doc.querySelectorAll('[data-ac-ghost]').forEach(el => el.remove());
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parse(html) {
|
|
34
|
+
return new DOMParser().parseFromString(`<!doctype html><body>${html}</body>`, 'text/html');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Wrap each {{ expr }} in a text node with a chip span.
|
|
38
|
+
function decorate(html) {
|
|
39
|
+
const doc = parse(html);
|
|
40
|
+
stripGhosts(doc);
|
|
41
|
+
const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT);
|
|
42
|
+
const nodes = [];
|
|
43
|
+
while (walker.nextNode()) nodes.push(walker.currentNode);
|
|
44
|
+
|
|
45
|
+
nodes.forEach(node => {
|
|
46
|
+
const parent = node.parentNode;
|
|
47
|
+
if (!parent || SKIP_PARENTS.includes(parent.nodeName)) return;
|
|
48
|
+
if (parent.nodeType === 1 && parent.hasAttribute('data-ac-var')) return;
|
|
49
|
+
const text = node.nodeValue;
|
|
50
|
+
if (!/\{\{[\s\S]*?\}\}/.test(text)) return;
|
|
51
|
+
|
|
52
|
+
const frag = doc.createDocumentFragment();
|
|
53
|
+
let last = 0;
|
|
54
|
+
let match;
|
|
55
|
+
VAR_RE.lastIndex = 0;
|
|
56
|
+
while ((match = VAR_RE.exec(text))) {
|
|
57
|
+
if (match.index > last) frag.appendChild(doc.createTextNode(text.slice(last, match.index)));
|
|
58
|
+
const span = doc.createElement('span');
|
|
59
|
+
span.setAttribute('data-ac-var', '');
|
|
60
|
+
span.setAttribute('data-ac-source', match[0]);
|
|
61
|
+
span.setAttribute('data-ac-id', nextId());
|
|
62
|
+
span.textContent = match[0];
|
|
63
|
+
frag.appendChild(span);
|
|
64
|
+
last = match.index + match[0].length;
|
|
65
|
+
}
|
|
66
|
+
if (last < text.length) frag.appendChild(doc.createTextNode(text.slice(last)));
|
|
67
|
+
parent.replaceChild(frag, node);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
return doc.body.innerHTML;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Replace each chip span with its text. The inverse of decorate().
|
|
74
|
+
function undecorate(html) {
|
|
75
|
+
// GrapesJS serializes text nodes the same way DOMParser does, so skipping the round trip when there are no chips changes nothing.
|
|
76
|
+
if (!html || (html.indexOf('data-ac-var') === -1 && html.indexOf('data-ac-ghost') === -1)) return html;
|
|
77
|
+
const doc = parse(html);
|
|
78
|
+
stripGhosts(doc);
|
|
79
|
+
doc.querySelectorAll('span[data-ac-var]').forEach(el => {
|
|
80
|
+
el.replaceWith(doc.createTextNode(sourceOf(el)));
|
|
81
|
+
});
|
|
82
|
+
return doc.body.innerHTML;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const CANVAS_CSS = `
|
|
86
|
+
span[data-ac-var] {
|
|
87
|
+
display: inline; background: #eef2ff; color: #3730a3; border: 1px solid #c7d2fe;
|
|
88
|
+
border-radius: 4px; padding: 0 4px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
89
|
+
font-size: 0.9em; white-space: nowrap; cursor: default;
|
|
90
|
+
}
|
|
91
|
+
[data-ac-for] { outline: 1px dashed #6366f1; outline-offset: 2px; }
|
|
92
|
+
[data-ac-for]::before {
|
|
93
|
+
content: "\\21BB " attr(data-ac-for); display: inline-block; font: 11px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
94
|
+
background: #6366f1; color: #fff; padding: 0 5px; border-radius: 3px; margin-right: 4px; vertical-align: top;
|
|
95
|
+
}
|
|
96
|
+
.ac-live-data span[data-ac-var] {
|
|
97
|
+
background: #dcfce7; color: #14532d; border-color: #86efac;
|
|
98
|
+
font-family: inherit; font-size: inherit; white-space: normal; cursor: help;
|
|
99
|
+
}
|
|
100
|
+
.ac-live-data span[data-ac-var].ac-chip-empty { opacity: 0.6; }
|
|
101
|
+
[data-ac-ghost] { opacity: 0.55; pointer-events: none; user-select: none; }
|
|
102
|
+
[data-ac-ghost] [data-ac-for]::before, [data-ac-ghost] [data-ac-if]::after { content: none; }
|
|
103
|
+
[data-ac-ghost] [data-ac-for], [data-ac-ghost] [data-ac-if] { outline: none; }
|
|
104
|
+
[data-ac-if] { outline: 1px dotted #f59e0b; outline-offset: 2px; }
|
|
105
|
+
[data-ac-if]::after {
|
|
106
|
+
content: "if " attr(data-ac-if); display: inline-block; font: 11px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
107
|
+
background: #f59e0b; color: #1f2937; padding: 0 5px; border-radius: 3px; margin-left: 4px; vertical-align: top;
|
|
108
|
+
}
|
|
109
|
+
`;
|
|
110
|
+
|
|
111
|
+
// GrapesJS plugin: registers the chip component type and styles the canvas.
|
|
112
|
+
function plugin(editor) {
|
|
113
|
+
editor.DomComponents.addType('ac-chip', {
|
|
114
|
+
isComponent: el => el.nodeType === 1 && el.tagName === 'SPAN' && el.hasAttribute('data-ac-var'),
|
|
115
|
+
model: {
|
|
116
|
+
defaults: {
|
|
117
|
+
name: 'Variable',
|
|
118
|
+
tagName: 'span',
|
|
119
|
+
editable: false,
|
|
120
|
+
droppable: false,
|
|
121
|
+
copyable: true,
|
|
122
|
+
removable: true,
|
|
123
|
+
layerable: false,
|
|
124
|
+
traits: [],
|
|
125
|
+
attributes: { 'data-ac-var': '' }
|
|
126
|
+
},
|
|
127
|
+
init() {
|
|
128
|
+
// The view marks the element contenteditable=false so the RTE skips
|
|
129
|
+
// it; that must never leak into the model (and so into the source).
|
|
130
|
+
this.removeAttributes('contenteditable');
|
|
131
|
+
// The model's text is always the source, even when the canvas shows
|
|
132
|
+
// a live value and the RTE re-parsed the element around it.
|
|
133
|
+
const attrs = this.getAttributes();
|
|
134
|
+
if (!attrs['data-ac-source']) {
|
|
135
|
+
this.addAttributes({ 'data-ac-source': this.components().map(c => c.get('content') || '').join('') });
|
|
136
|
+
}
|
|
137
|
+
if (!attrs['data-ac-id']) this.addAttributes({ 'data-ac-id': nextId() });
|
|
138
|
+
const source = this.getAttributes()['data-ac-source'];
|
|
139
|
+
const text = this.components().map(c => c.get('content') || '').join('');
|
|
140
|
+
if (text !== source) this.components([{ type: 'textnode', content: source }]);
|
|
141
|
+
},
|
|
142
|
+
source() {
|
|
143
|
+
return this.getAttributes()['data-ac-source'] || '';
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
view: {
|
|
147
|
+
events: { dblclick: 'editExpression' },
|
|
148
|
+
onRender() {
|
|
149
|
+
this.el.setAttribute('contenteditable', 'false');
|
|
150
|
+
if (window.ActiveCanvasLiveData) window.ActiveCanvasLiveData.applyChip(this.el);
|
|
151
|
+
},
|
|
152
|
+
editExpression(e) {
|
|
153
|
+
e.preventDefault();
|
|
154
|
+
e.stopPropagation();
|
|
155
|
+
const next = window.prompt('Liquid expression', this.model.source());
|
|
156
|
+
if (next === null) return;
|
|
157
|
+
const expr = next.trim();
|
|
158
|
+
if (!expr) return;
|
|
159
|
+
const tag = expr.startsWith('{{') ? expr : `{{ ${expr} }}`;
|
|
160
|
+
this.model.addAttributes({ 'data-ac-source': tag });
|
|
161
|
+
this.model.components([{ type: 'textnode', content: tag }]);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// Text typed into the canvas becomes chips once the author leaves the
|
|
167
|
+
// element, so new {{ }} tags get locked and can show live values.
|
|
168
|
+
editor.on('rte:disable', view => {
|
|
169
|
+
const component = view && view.model;
|
|
170
|
+
if (!component || !component.getInnerHTML) return;
|
|
171
|
+
setTimeout(() => {
|
|
172
|
+
// Ghost copies caught by the RTE re-parse must not become components.
|
|
173
|
+
component.find('[data-ac-ghost]').forEach(ghost => ghost.remove());
|
|
174
|
+
const inner = component.getInnerHTML();
|
|
175
|
+
if (!/\{\{[\s\S]*?\}\}/.test(undecorate(inner))) return;
|
|
176
|
+
const decorated = decorate(inner);
|
|
177
|
+
if (decorated !== inner) component.components(decorated);
|
|
178
|
+
}, 0);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
editor.on('load', () => {
|
|
182
|
+
const doc = editor.Canvas.getDocument();
|
|
183
|
+
if (!doc || doc.getElementById('ac-chips-css')) return;
|
|
184
|
+
const style = doc.createElement('style');
|
|
185
|
+
style.id = 'ac-chips-css';
|
|
186
|
+
style.textContent = CANVAS_CSS;
|
|
187
|
+
doc.head.appendChild(style);
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
window.ActiveCanvasChips = { decorate, undecorate, sourceOf, plugin };
|
|
192
|
+
})();
|
|
@@ -35,6 +35,9 @@
|
|
|
35
35
|
let editingComponent = null;
|
|
36
36
|
let isComponentMode = false;
|
|
37
37
|
|
|
38
|
+
const toSource = html => (window.ActiveCanvasChips ? window.ActiveCanvasChips.undecorate(html) : html);
|
|
39
|
+
const toCanvas = html => (window.ActiveCanvasChips && config.templateEnabled ? window.ActiveCanvasChips.decorate(html) : html);
|
|
40
|
+
|
|
38
41
|
// Initialize Monaco editors
|
|
39
42
|
function initMonacoEditors() {
|
|
40
43
|
if (monacoInitialized) return;
|
|
@@ -91,7 +94,7 @@
|
|
|
91
94
|
|
|
92
95
|
// Create HTML editor
|
|
93
96
|
htmlMonacoEditor = monaco.editor.create(document.getElementById('monaco-html-container'), {
|
|
94
|
-
value: editor.getHtml(),
|
|
97
|
+
value: toSource(editor.getHtml()),
|
|
95
98
|
language: 'html',
|
|
96
99
|
...editorOptions
|
|
97
100
|
});
|
|
@@ -205,9 +208,9 @@
|
|
|
205
208
|
isSyncing = true;
|
|
206
209
|
|
|
207
210
|
if (isComponentMode && editingComponent) {
|
|
208
|
-
htmlMonacoEditor.setValue(editingComponent.toHTML());
|
|
211
|
+
htmlMonacoEditor.setValue(toSource(editingComponent.toHTML()));
|
|
209
212
|
} else {
|
|
210
|
-
htmlMonacoEditor.setValue(editor.getHtml());
|
|
213
|
+
htmlMonacoEditor.setValue(toSource(editor.getHtml()));
|
|
211
214
|
}
|
|
212
215
|
cssMonacoEditor.setValue(editor.getCss());
|
|
213
216
|
setTimeout(async () => {
|
|
@@ -226,11 +229,11 @@
|
|
|
226
229
|
try {
|
|
227
230
|
if (isComponentMode && editingComponent) {
|
|
228
231
|
const newHtml = htmlMonacoEditor.getValue();
|
|
229
|
-
updateComponentFromHtml(editingComponent, newHtml);
|
|
232
|
+
updateComponentFromHtml(editingComponent, toCanvas(newHtml));
|
|
230
233
|
} else {
|
|
231
234
|
const newHtml = htmlMonacoEditor.getValue();
|
|
232
235
|
const newCss = cssMonacoEditor.getValue();
|
|
233
|
-
editor.setComponents(newHtml);
|
|
236
|
+
editor.setComponents(toCanvas(newHtml));
|
|
234
237
|
editor.setStyle(newCss);
|
|
235
238
|
}
|
|
236
239
|
if (statusEl) {
|
|
@@ -296,7 +299,7 @@
|
|
|
296
299
|
initMonacoEditors();
|
|
297
300
|
setTimeout(() => {
|
|
298
301
|
isSyncing = true;
|
|
299
|
-
htmlMonacoEditor.setValue(component.toHTML());
|
|
302
|
+
htmlMonacoEditor.setValue(toSource(component.toHTML()));
|
|
300
303
|
setTimeout(async () => {
|
|
301
304
|
try {
|
|
302
305
|
await htmlMonacoEditor.getAction('editor.action.formatDocument').run();
|
|
@@ -314,7 +317,7 @@
|
|
|
314
317
|
}, 200);
|
|
315
318
|
} else {
|
|
316
319
|
isSyncing = true;
|
|
317
|
-
htmlMonacoEditor.setValue(component.toHTML());
|
|
320
|
+
htmlMonacoEditor.setValue(toSource(component.toHTML()));
|
|
318
321
|
setTimeout(async () => {
|
|
319
322
|
try {
|
|
320
323
|
await htmlMonacoEditor.getAction('editor.action.formatDocument').run();
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ActiveCanvas Editor - Loop and condition traits
|
|
3
|
+
*
|
|
4
|
+
* Every selectable component gets three traits in the Settings tab:
|
|
5
|
+
* Repeat for each -> data-ac-for="<item> in <binding>"
|
|
6
|
+
* Item name -> the <item> part
|
|
7
|
+
* Show only if -> data-ac-if="<condition>"
|
|
8
|
+
* The traits are (re)built on selection so the binding list is current.
|
|
9
|
+
* Liquid loop modifiers after the binding (limit:, offset:, reversed) are
|
|
10
|
+
* kept as they are when the item or the binding changes.
|
|
11
|
+
*/
|
|
12
|
+
(function() {
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
window.ActiveCanvasEditor = window.ActiveCanvasEditor || {};
|
|
16
|
+
|
|
17
|
+
const SKIP_TYPES = ['wrapper', 'textnode', 'comment', 'ac-chip'];
|
|
18
|
+
const PROPS = ['ac-repeat', 'ac-item', 'ac-tail'];
|
|
19
|
+
|
|
20
|
+
function setupDirectiveTraits(editor, config) {
|
|
21
|
+
if (!config.templateEnabled) return;
|
|
22
|
+
|
|
23
|
+
editor.on('component:selected', component => {
|
|
24
|
+
if (!component || SKIP_TYPES.includes(component.get('type'))) return;
|
|
25
|
+
ensureTraits(component);
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function parseFor(value) {
|
|
30
|
+
const match = /^\s*(\S+)\s+in\s+(\S+)(.*)$/.exec(value || '');
|
|
31
|
+
return match
|
|
32
|
+
? { item: match[1], binding: match[2], tail: match[3].trim() }
|
|
33
|
+
: { item: '', binding: '', tail: '' };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function ensureTraits(component) {
|
|
37
|
+
const bindings = window.ActiveCanvasBindings;
|
|
38
|
+
const current = parseFor(component.getAttributes()['data-ac-for']);
|
|
39
|
+
|
|
40
|
+
['ac-repeat', 'ac-item', 'data-ac-if'].forEach(name => component.removeTrait(name));
|
|
41
|
+
component.set({ 'ac-repeat': current.binding, 'ac-item': current.item, 'ac-tail': current.tail }, { silent: true });
|
|
42
|
+
|
|
43
|
+
const names = bindings.listNames();
|
|
44
|
+
const options = [{ id: '', name: '— none —' }].concat(names.map(name => ({ id: name, name })));
|
|
45
|
+
if (current.binding && !names.includes(current.binding)) {
|
|
46
|
+
const note = bindings.read()[current.binding] ? 'not a list' : 'missing';
|
|
47
|
+
options.push({ id: current.binding, name: `${current.binding} (${note})` });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
component.addTrait([
|
|
51
|
+
{ type: 'select', name: 'ac-repeat', label: 'Repeat for each', changeProp: true, options },
|
|
52
|
+
{ type: 'text', name: 'ac-item', label: 'Item name', changeProp: true, placeholder: 'item' },
|
|
53
|
+
{ type: 'text', name: 'data-ac-if', label: 'Show only if', placeholder: `${current.item || 'item'}.featured` }
|
|
54
|
+
], { at: 0 });
|
|
55
|
+
|
|
56
|
+
if (!component.__acDirectivesWired) {
|
|
57
|
+
component.__acDirectivesWired = true;
|
|
58
|
+
component.set('_undoexc', (component.get('_undoexc') || []).concat(PROPS), { silent: true });
|
|
59
|
+
component.on('change:ac-repeat change:ac-item', () => applyFor(component));
|
|
60
|
+
component.on('change:attributes:data-ac-for', () => syncFromAttribute(component));
|
|
61
|
+
component.on('change:attributes:data-ac-if', () => {
|
|
62
|
+
const value = (component.getAttributes()['data-ac-if'] || '').trim();
|
|
63
|
+
if (!value) component.removeAttributes('data-ac-if');
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function applyFor(component) {
|
|
69
|
+
const binding = component.get('ac-repeat');
|
|
70
|
+
if (!binding) {
|
|
71
|
+
if ('data-ac-for' in component.getAttributes()) component.removeAttributes('data-ac-for');
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
let item = (component.get('ac-item') || '').trim();
|
|
75
|
+
if (!item) {
|
|
76
|
+
item = window.ActiveCanvasBindings.itemName(binding);
|
|
77
|
+
component.set('ac-item', item); // not silent: the trait input must show it
|
|
78
|
+
return; // the change:ac-item handler calls applyFor again with the item set
|
|
79
|
+
}
|
|
80
|
+
const tail = component.get('ac-tail') || '';
|
|
81
|
+
const next = `${item} in ${binding}${tail ? ' ' + tail : ''}`;
|
|
82
|
+
if (component.getAttributes()['data-ac-for'] !== next) component.addAttributes({ 'data-ac-for': next });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Keeps the trait values in step with the attribute when it changes behind
|
|
86
|
+
// the traits' back (undo, code panel, another handler). Non-silent so the
|
|
87
|
+
// inputs re-render; applyFor is a no-op when nothing differs.
|
|
88
|
+
function syncFromAttribute(component) {
|
|
89
|
+
const parsed = parseFor(component.getAttributes()['data-ac-for']);
|
|
90
|
+
if (component.get('ac-repeat') === parsed.binding && component.get('ac-item') === parsed.item && component.get('ac-tail') === parsed.tail) return;
|
|
91
|
+
component.set({ 'ac-repeat': parsed.binding, 'ac-item': parsed.item, 'ac-tail': parsed.tail });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
window.ActiveCanvasEditor.setupDirectiveTraits = setupDirectiveTraits;
|
|
95
|
+
})();
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ActiveCanvas Editor - Live data
|
|
3
|
+
*
|
|
4
|
+
* With the "Live data" switch on, every chip in the canvas shows the value
|
|
5
|
+
* it renders, a loop shows all its items (the first one is the editable
|
|
6
|
+
* element, the others are dimmed ghost copies), and an element whose
|
|
7
|
+
* condition is false is dimmed with a "hidden" badge. This is a view layer
|
|
8
|
+
* only: the chip's source stays in its data-ac-source attribute and ghosts
|
|
9
|
+
* never enter the model, so saving, validating and the code panel keep
|
|
10
|
+
* working on the source. Values come from chip_values, refreshed after
|
|
11
|
+
* edits and binding changes.
|
|
12
|
+
*/
|
|
13
|
+
(function() {
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
window.ActiveCanvasEditor = window.ActiveCanvasEditor || {};
|
|
17
|
+
|
|
18
|
+
const state = { enabled: false, values: {}, loops: [], conds: [], doc: null };
|
|
19
|
+
|
|
20
|
+
function applyChip(el) {
|
|
21
|
+
const source = window.ActiveCanvasChips.sourceOf(el);
|
|
22
|
+
if (el.closest('[data-ac-ghost]')) return; // ghost copies arrive rendered
|
|
23
|
+
if (!state.enabled) {
|
|
24
|
+
if (el.textContent !== source) el.textContent = source;
|
|
25
|
+
el.removeAttribute('title');
|
|
26
|
+
el.classList.remove('ac-chip-empty');
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const id = el.getAttribute('data-ac-id');
|
|
30
|
+
const has = !!id && Object.prototype.hasOwnProperty.call(state.values, id);
|
|
31
|
+
const value = has ? state.values[id] : null;
|
|
32
|
+
const shown = has ? (value === '' ? '\u2014' : value) : source;
|
|
33
|
+
if (el.textContent !== shown) el.textContent = shown;
|
|
34
|
+
el.title = has ? source : `${source} (no value yet)`;
|
|
35
|
+
el.classList.toggle('ac-chip-empty', has && value === '');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function cssString(text) {
|
|
39
|
+
return `"${String(text).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Loop and condition elements in the order the server numbered them:
|
|
43
|
+
// document order, ghosts excluded.
|
|
44
|
+
function directiveElements(doc, attr) {
|
|
45
|
+
return [...doc.querySelectorAll(`[${attr}]`)].filter(el => !el.closest('[data-ac-ghost]'));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function removeGhosts(doc) {
|
|
49
|
+
doc.querySelectorAll('[data-ac-ghost]').forEach(el => el.remove());
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// The other items of a loop, inserted after the real element as dimmed,
|
|
53
|
+
// unselectable copies. They are never components, and the chips module
|
|
54
|
+
// strips them from anything that reaches the source.
|
|
55
|
+
function insertGhosts(doc) {
|
|
56
|
+
directiveElements(doc, 'data-ac-for').forEach((el, i) => {
|
|
57
|
+
const loop = state.loops[i];
|
|
58
|
+
if (!loop || !loop.copies) return;
|
|
59
|
+
let anchor = el;
|
|
60
|
+
loop.copies.forEach(html => {
|
|
61
|
+
anchor.insertAdjacentHTML('afterend', html);
|
|
62
|
+
const ghost = anchor.nextElementSibling;
|
|
63
|
+
if (!ghost) return;
|
|
64
|
+
ghost.setAttribute('data-ac-ghost', '');
|
|
65
|
+
ghost.setAttribute('contenteditable', 'false');
|
|
66
|
+
anchor = ghost;
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Counts and hidden conditions are shown through generated CSS keyed by
|
|
72
|
+
// the expression, so no attribute or class is added to the element itself
|
|
73
|
+
// (those would end up in the saved source).
|
|
74
|
+
function applyBadges(doc) {
|
|
75
|
+
let style = doc.getElementById('ac-live-badges');
|
|
76
|
+
if (!state.enabled) { if (style) style.remove(); return; }
|
|
77
|
+
if (!style) {
|
|
78
|
+
style = doc.createElement('style');
|
|
79
|
+
style.id = 'ac-live-badges';
|
|
80
|
+
doc.head.appendChild(style);
|
|
81
|
+
}
|
|
82
|
+
const rules = [];
|
|
83
|
+
state.loops.forEach(loop => {
|
|
84
|
+
const shown = 1 + (loop.copies ? loop.copies.length : 0);
|
|
85
|
+
const more = loop.count > shown ? `, ${shown} shown` : '';
|
|
86
|
+
const label = `\u21BB ${loop.expr} \u00B7 ${loop.count} item${loop.count === 1 ? '' : 's'}${more}`;
|
|
87
|
+
rules.push(`[data-ac-for=${cssString(loop.expr)}]::before { content: ${cssString(label)}; }`);
|
|
88
|
+
});
|
|
89
|
+
state.conds.forEach(cond => {
|
|
90
|
+
const label = cond.shown ? `if ${cond.expr}` : `if ${cond.expr} \u00B7 hidden`;
|
|
91
|
+
rules.push(`[data-ac-if=${cssString(cond.expr)}]::after { content: ${cssString(label)}; }`);
|
|
92
|
+
if (!cond.shown) rules.push(`[data-ac-if=${cssString(cond.expr)}] { opacity: 0.35; }`);
|
|
93
|
+
});
|
|
94
|
+
style.textContent = rules.join('\n');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function render() {
|
|
98
|
+
const doc = state.doc;
|
|
99
|
+
const btn = document.getElementById('btn-live-data');
|
|
100
|
+
if (btn) {
|
|
101
|
+
btn.classList.toggle('active', state.enabled);
|
|
102
|
+
btn.setAttribute('aria-pressed', String(state.enabled));
|
|
103
|
+
}
|
|
104
|
+
if (!doc) return;
|
|
105
|
+
removeGhosts(doc);
|
|
106
|
+
doc.body.classList.toggle('ac-live-data', state.enabled);
|
|
107
|
+
doc.querySelectorAll('span[data-ac-var]').forEach(applyChip);
|
|
108
|
+
if (state.enabled) insertGhosts(doc);
|
|
109
|
+
applyBadges(doc);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function setupLiveData(editor, config) {
|
|
113
|
+
const container = document.getElementById('data-panel-container');
|
|
114
|
+
const btn = document.getElementById('btn-live-data');
|
|
115
|
+
if (!container || !btn) return;
|
|
116
|
+
if (!config.templateEnabled) { btn.hidden = true; return; }
|
|
117
|
+
|
|
118
|
+
const url = container.dataset.chipValuesUrl;
|
|
119
|
+
const key = `ac:livedata:${config.pageId}`;
|
|
120
|
+
const csrf = window.ActiveCanvasEditor.getCsrfToken();
|
|
121
|
+
let seq = 0;
|
|
122
|
+
let timer = null;
|
|
123
|
+
let lastFingerprint = null;
|
|
124
|
+
|
|
125
|
+
try { state.enabled = localStorage.getItem(key) !== 'off'; } catch (e) { state.enabled = true; }
|
|
126
|
+
|
|
127
|
+
function fingerprint(html) {
|
|
128
|
+
return (html.match(/data-ac-source="[^"]*"|data-ac-(?:for|if)="[^"]*"/g) || []).join('\n') + '\n' + window.ActiveCanvasBindings.readJson();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function refresh(force) {
|
|
132
|
+
if (!state.enabled) return;
|
|
133
|
+
const html = editor.getHtml();
|
|
134
|
+
const fp = fingerprint(html);
|
|
135
|
+
if (!force && fp === lastFingerprint) return;
|
|
136
|
+
lastFingerprint = fp;
|
|
137
|
+
const id = ++seq;
|
|
138
|
+
|
|
139
|
+
fetch(url, {
|
|
140
|
+
method: 'POST',
|
|
141
|
+
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf, 'Accept': 'application/json' },
|
|
142
|
+
body: JSON.stringify({ content: html, bindings: window.ActiveCanvasBindings.readJson() })
|
|
143
|
+
})
|
|
144
|
+
.then(r => r.json())
|
|
145
|
+
.then(body => {
|
|
146
|
+
if (id !== seq) return;
|
|
147
|
+
state.values = body.values || {};
|
|
148
|
+
state.loops = Array.isArray(body.loops) ? body.loops : [];
|
|
149
|
+
state.conds = Array.isArray(body.conds) ? body.conds : [];
|
|
150
|
+
render();
|
|
151
|
+
})
|
|
152
|
+
.catch(() => { /* the validator banner reports server trouble */ });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function schedule() {
|
|
156
|
+
clearTimeout(timer);
|
|
157
|
+
timer = setTimeout(() => refresh(false), 1500);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
btn.addEventListener('click', () => {
|
|
161
|
+
state.enabled = !state.enabled;
|
|
162
|
+
try { localStorage.setItem(key, state.enabled ? 'on' : 'off'); } catch (e) { /* storage unavailable */ }
|
|
163
|
+
render();
|
|
164
|
+
refresh(true);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
editor.on('load', () => {
|
|
168
|
+
state.doc = editor.Canvas.getDocument();
|
|
169
|
+
render();
|
|
170
|
+
refresh(true);
|
|
171
|
+
});
|
|
172
|
+
editor.on('component:update component:add component:remove', schedule);
|
|
173
|
+
document.addEventListener('ac:bindings-changed', () => refresh(true));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
window.ActiveCanvasLiveData = { applyChip, enabled: () => state.enabled, removeGhosts };
|
|
177
|
+
window.ActiveCanvasEditor.setupLiveData = setupLiveData;
|
|
178
|
+
})();
|
|
@@ -93,10 +93,12 @@
|
|
|
93
93
|
const blocksContainer = document.getElementById('blocks-container');
|
|
94
94
|
const assetsContainer = document.getElementById('assets-container');
|
|
95
95
|
const layersContainer = document.getElementById('layers-container');
|
|
96
|
+
const dataContainer = document.getElementById('data-panel-container');
|
|
96
97
|
|
|
97
98
|
if (blocksContainer) blocksContainer.style.display = panel === 'blocks' ? 'block' : 'none';
|
|
98
99
|
if (assetsContainer) assetsContainer.style.display = panel === 'assets' ? 'block' : 'none';
|
|
99
100
|
if (layersContainer) layersContainer.style.display = panel === 'layers' ? 'block' : 'none';
|
|
101
|
+
if (dataContainer) dataContainer.style.display = panel === 'data' ? '' : 'none';
|
|
100
102
|
} else {
|
|
101
103
|
const stylesContainer = document.getElementById('styles-container');
|
|
102
104
|
const traitsContainer = document.getElementById('traits-container');
|
|
@@ -192,28 +194,37 @@
|
|
|
192
194
|
saveContent(true);
|
|
193
195
|
}, 60000);
|
|
194
196
|
|
|
195
|
-
function saveContent(isAutoSave) {
|
|
196
|
-
if (saveInProgress) return;
|
|
197
|
+
function saveContent(isAutoSave, extra) {
|
|
198
|
+
if (saveInProgress) return Promise.resolve(false);
|
|
197
199
|
saveInProgress = true;
|
|
198
200
|
|
|
199
201
|
if (saveBtn) saveBtn.disabled = true;
|
|
200
202
|
|
|
201
|
-
|
|
203
|
+
// The canvas holds the Liquid source with chip wrappers; strip them.
|
|
204
|
+
const rawHtml = editor.getHtml();
|
|
205
|
+
const html = window.ActiveCanvasChips ? window.ActiveCanvasChips.undecorate(rawHtml) : rawHtml;
|
|
202
206
|
const css = editor.getCss();
|
|
203
207
|
const js = window.ActiveCanvasEditor.getJs ? window.ActiveCanvasEditor.getJs() : '';
|
|
204
|
-
|
|
208
|
+
// Dynamic pages reload from `content`; their components JSON would only
|
|
209
|
+
// carry chip wrappers, so it is not persisted.
|
|
210
|
+
const components = config.templateEnabled ? null : JSON.stringify(editor.getComponents());
|
|
205
211
|
|
|
206
212
|
// Use entityType from config (defaults to 'page' for backwards compatibility)
|
|
207
213
|
const entityType = config.entityType || 'page';
|
|
214
|
+
const bindingsJson = window.ActiveCanvasBindings ? window.ActiveCanvasBindings.readJson() : '{}';
|
|
208
215
|
const payload = {};
|
|
209
216
|
payload[entityType] = {
|
|
210
217
|
content: html,
|
|
211
218
|
content_css: css,
|
|
212
219
|
content_js: js,
|
|
213
|
-
content_components: components
|
|
220
|
+
content_components: components,
|
|
221
|
+
bindings: bindingsJson
|
|
214
222
|
};
|
|
223
|
+
// The Data tab's dynamic-rendering toggle passes template_enabled here;
|
|
224
|
+
// otherwise the page form remains the authority for that field.
|
|
225
|
+
Object.assign(payload[entityType], extra || {});
|
|
215
226
|
|
|
216
|
-
fetch(config.saveUrl, {
|
|
227
|
+
return fetch(config.saveUrl, {
|
|
217
228
|
method: 'PATCH',
|
|
218
229
|
headers: {
|
|
219
230
|
'Content-Type': 'application/json',
|
|
@@ -234,24 +245,29 @@
|
|
|
234
245
|
message += ` · Tailwind compiled (${sizeKb}KB in ${result.tailwind.elapsed_ms}ms)`;
|
|
235
246
|
} else {
|
|
236
247
|
showToast('Page saved, but Tailwind compilation failed: ' + result.tailwind.error, 'warning');
|
|
237
|
-
return;
|
|
248
|
+
return true; // the page itself was saved
|
|
238
249
|
}
|
|
239
250
|
}
|
|
240
251
|
|
|
241
252
|
showToast(message, 'success');
|
|
253
|
+
return true;
|
|
242
254
|
} else {
|
|
243
255
|
showToast(result.errors ? result.errors.join(', ') : 'Save failed', 'error');
|
|
256
|
+
return false;
|
|
244
257
|
}
|
|
245
258
|
})
|
|
246
259
|
.catch(error => {
|
|
247
260
|
showToast('Save failed', 'error');
|
|
248
261
|
console.error('Save error:', error);
|
|
262
|
+
return false;
|
|
249
263
|
})
|
|
250
264
|
.finally(() => {
|
|
251
265
|
saveInProgress = false;
|
|
252
266
|
if (saveBtn) saveBtn.disabled = false;
|
|
253
267
|
});
|
|
254
268
|
}
|
|
269
|
+
|
|
270
|
+
window.ActiveCanvasEditor.saveContent = saveContent;
|
|
255
271
|
}
|
|
256
272
|
|
|
257
273
|
/**
|