active_canvas 0.0.4 → 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.
Files changed (76) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +23 -1
  3. data/app/assets/javascripts/active_canvas/admin/data_panel.js +357 -0
  4. data/app/assets/javascripts/active_canvas/admin/field_builder.js +84 -0
  5. data/app/assets/javascripts/active_canvas/admin/media_select_preview.js +11 -0
  6. data/app/assets/javascripts/active_canvas/editor/ac_bindings.js +81 -0
  7. data/app/assets/javascripts/active_canvas/editor/ac_chips.js +192 -0
  8. data/app/assets/javascripts/active_canvas/editor/code_panel.js +10 -7
  9. data/app/assets/javascripts/active_canvas/editor/directive_traits.js +95 -0
  10. data/app/assets/javascripts/active_canvas/editor/live_data.js +178 -0
  11. data/app/assets/javascripts/active_canvas/editor/panels.js +23 -7
  12. data/app/assets/javascripts/active_canvas/editor/preview_modal.js +112 -0
  13. data/app/assets/javascripts/active_canvas/editor/template_validator.js +95 -0
  14. data/app/assets/javascripts/active_canvas/editor.js +21 -3
  15. data/app/assets/stylesheets/active_canvas/editor.css +626 -0
  16. data/app/controllers/active_canvas/admin/application_controller.rb +8 -0
  17. data/app/controllers/active_canvas/admin/collection_items_controller.rb +93 -0
  18. data/app/controllers/active_canvas/admin/collections_controller.rb +51 -0
  19. data/app/controllers/active_canvas/admin/form_submissions_controller.rb +51 -0
  20. data/app/controllers/active_canvas/admin/pages_controller.rb +127 -5
  21. data/app/controllers/active_canvas/form_submissions_controller.rb +90 -0
  22. data/app/controllers/active_canvas/pages_controller.rb +8 -0
  23. data/app/controllers/active_canvas/redirects_controller.rb +10 -0
  24. data/app/helpers/active_canvas/admin/collection_items_helper.rb +33 -0
  25. data/app/models/active_canvas/auto_drop.rb +69 -0
  26. data/app/models/active_canvas/collection.rb +98 -0
  27. data/app/models/active_canvas/collection_item.rb +66 -0
  28. data/app/models/active_canvas/collection_item_version.rb +18 -0
  29. data/app/models/active_canvas/current.rb +2 -0
  30. data/app/models/active_canvas/form_submission.rb +9 -0
  31. data/app/models/active_canvas/page.rb +76 -15
  32. data/app/models/active_canvas/page_redirect.rb +7 -0
  33. data/app/services/active_canvas/collection_schema.rb +110 -0
  34. data/app/services/active_canvas/collection_source.rb +81 -0
  35. data/app/services/active_canvas/content_sanitizer.rb +33 -21
  36. data/app/services/active_canvas/form_schema.rb +48 -0
  37. data/app/services/active_canvas/form_stamper.rb +86 -0
  38. data/app/services/active_canvas/template_renderer/binding_resolver.rb +138 -0
  39. data/app/services/active_canvas/template_renderer/directive_expander.rb +54 -0
  40. data/app/services/active_canvas/template_renderer.rb +160 -0
  41. data/app/views/active_canvas/admin/collection_items/_field_input.html.erb +40 -0
  42. data/app/views/active_canvas/admin/collection_items/_form.html.erb +28 -0
  43. data/app/views/active_canvas/admin/collection_items/edit.html.erb +24 -0
  44. data/app/views/active_canvas/admin/collection_items/history.html.erb +34 -0
  45. data/app/views/active_canvas/admin/collection_items/index.html.erb +63 -0
  46. data/app/views/active_canvas/admin/collection_items/new.html.erb +9 -0
  47. data/app/views/active_canvas/admin/collections/_field_builder.html.erb +35 -0
  48. data/app/views/active_canvas/admin/collections/_form.html.erb +31 -0
  49. data/app/views/active_canvas/admin/collections/edit.html.erb +10 -0
  50. data/app/views/active_canvas/admin/collections/index.html.erb +45 -0
  51. data/app/views/active_canvas/admin/collections/new.html.erb +9 -0
  52. data/app/views/active_canvas/admin/form_submissions/index.html.erb +80 -0
  53. data/app/views/active_canvas/admin/form_submissions/show.html.erb +99 -0
  54. data/app/views/active_canvas/admin/pages/_data_panel.html.erb +81 -0
  55. data/app/views/active_canvas/admin/pages/_form.html.erb +10 -0
  56. data/app/views/active_canvas/admin/pages/editor.html.erb +62 -1
  57. data/app/views/active_canvas/pages/show.html.erb +4 -1
  58. data/app/views/layouts/active_canvas/admin/application.html.erb +15 -0
  59. data/config/routes.rb +20 -0
  60. data/db/migrate/20260517100001_add_template_enabled_and_bindings_to_active_canvas_pages.rb +6 -0
  61. data/db/migrate/20260517100002_add_bindings_to_active_canvas_page_versions.rb +6 -0
  62. data/db/migrate/20260702000001_create_active_canvas_page_redirects.rb +11 -0
  63. data/db/migrate/20260702000002_create_active_canvas_form_submissions.rb +14 -0
  64. data/db/migrate/20260702000003_create_active_canvas_collections.rb +12 -0
  65. data/db/migrate/20260702000004_create_active_canvas_collection_items.rb +16 -0
  66. data/db/migrate/20260702000005_create_active_canvas_collection_item_versions.rb +17 -0
  67. data/lib/active_canvas/configuration.rb +32 -1
  68. data/lib/active_canvas/data_sources/errors.rb +51 -0
  69. data/lib/active_canvas/data_sources/param_spec.rb +64 -0
  70. data/lib/active_canvas/data_sources/registration.rb +60 -0
  71. data/lib/active_canvas/data_sources/source.rb +47 -0
  72. data/lib/active_canvas/data_sources.rb +54 -0
  73. data/lib/active_canvas/engine.rb +15 -0
  74. data/lib/active_canvas/version.rb +1 -1
  75. data/lib/active_canvas.rb +6 -0
  76. 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
- const html = editor.getHtml();
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
- const components = JSON.stringify(editor.getComponents());
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
  /**