@fleetbase/ember-ui 0.3.23 → 0.3.24

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 (43) hide show
  1. package/addon/components/template-builder/canvas.hbs +23 -0
  2. package/addon/components/template-builder/canvas.js +116 -0
  3. package/addon/components/template-builder/element-renderer.hbs +126 -0
  4. package/addon/components/template-builder/element-renderer.js +398 -0
  5. package/addon/components/template-builder/layers-panel.hbs +99 -0
  6. package/addon/components/template-builder/layers-panel.js +146 -0
  7. package/addon/components/template-builder/properties-panel/field.hbs +7 -0
  8. package/addon/components/template-builder/properties-panel/field.js +9 -0
  9. package/addon/components/template-builder/properties-panel/section.hbs +24 -0
  10. package/addon/components/template-builder/properties-panel/section.js +19 -0
  11. package/addon/components/template-builder/properties-panel.hbs +576 -0
  12. package/addon/components/template-builder/properties-panel.js +413 -0
  13. package/addon/components/template-builder/queries-panel.hbs +84 -0
  14. package/addon/components/template-builder/queries-panel.js +88 -0
  15. package/addon/components/template-builder/query-form.hbs +260 -0
  16. package/addon/components/template-builder/query-form.js +309 -0
  17. package/addon/components/template-builder/toolbar.hbs +134 -0
  18. package/addon/components/template-builder/toolbar.js +106 -0
  19. package/addon/components/template-builder/variable-picker.hbs +210 -0
  20. package/addon/components/template-builder/variable-picker.js +181 -0
  21. package/addon/components/template-builder.hbs +119 -0
  22. package/addon/components/template-builder.js +567 -0
  23. package/addon/helpers/string-starts-with.js +14 -0
  24. package/addon/services/template-builder.js +72 -0
  25. package/addon/styles/addon.css +1 -0
  26. package/addon/styles/components/badge.css +66 -12
  27. package/addon/styles/components/template-builder.css +297 -0
  28. package/addon/utils/get-currency.js +1 -1
  29. package/app/components/template-builder/canvas.js +1 -0
  30. package/app/components/template-builder/element-renderer.js +1 -0
  31. package/app/components/template-builder/layers-panel.js +1 -0
  32. package/app/components/template-builder/properties-panel/field.js +1 -0
  33. package/app/components/template-builder/properties-panel/section.js +1 -0
  34. package/app/components/template-builder/properties-panel.js +1 -0
  35. package/app/components/template-builder/queries-panel.js +1 -0
  36. package/app/components/template-builder/query-form.js +1 -0
  37. package/app/components/template-builder/toolbar.js +1 -0
  38. package/app/components/template-builder/variable-picker.js +1 -0
  39. package/app/components/template-builder.js +1 -0
  40. package/app/helpers/string-starts-with.js +1 -0
  41. package/app/services/template-builder.js +1 -0
  42. package/package.json +3 -2
  43. package/tsconfig.declarations.json +8 -8
@@ -0,0 +1,119 @@
1
+ {{! Template Builder — top-level layout }}
2
+ <div class="tb-root flex flex-col h-full w-full bg-gray-100 dark:bg-gray-950 overflow-hidden" ...attributes>
3
+
4
+ {{! ================================================================ }}
5
+ {{! TOOLBAR }}
6
+ {{! ================================================================ }}
7
+ <TemplateBuilder::Toolbar
8
+ @zoom={{this.zoom}}
9
+ @selectedElement={{this.selectedElement}}
10
+ @canUndo={{this.canUndo}}
11
+ @canRedo={{this.canRedo}}
12
+ @isSaving={{@isSaving}}
13
+ @onAddElement={{this.addElement}}
14
+ @onZoomIn={{this.zoomIn}}
15
+ @onZoomOut={{this.zoomOut}}
16
+ @onZoomReset={{this.zoomReset}}
17
+ @onUndo={{this.undo}}
18
+ @onRedo={{this.redo}}
19
+ @onPreview={{this.preview}}
20
+ @onSave={{this.save}}
21
+ @onRotateElement={{this.rotateElement}}
22
+ @onClose={{@onClose}}
23
+ @closeIcon={{@closeIcon}}
24
+ @closeLabel={{@closeLabel}}
25
+ />
26
+
27
+ {{! ================================================================ }}
28
+ {{! MAIN AREA: Layers/Queries | Canvas | Properties }}
29
+ {{! ================================================================ }}
30
+ <div class="flex flex-1 min-h-0 overflow-hidden">
31
+
32
+ {{! LEFT: Layers + Queries panel }}
33
+ <div class="tb-panel-left w-52 flex-shrink-0 border-r border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 overflow-hidden flex flex-col">
34
+
35
+ {{! Tab switcher }}
36
+ <div class="flex border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
37
+ <button
38
+ type="button"
39
+ class="flex-1 flex items-center justify-center space-x-1.5 py-2 text-xs font-medium transition-colors
40
+ {{if (eq this.leftPanelTab 'layers')
41
+ 'text-blue-600 dark:text-blue-400 border-b-2 border-blue-500 -mb-px'
42
+ 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'}}"
43
+ {{on "click" (fn this.setLeftPanelTab "layers")}}
44
+ >
45
+ <FaIcon @icon="layer-group" class="w-3 h-3" />
46
+ <span>Layers</span>
47
+ </button>
48
+ <button
49
+ type="button"
50
+ class="flex-1 flex items-center justify-center space-x-1.5 py-2 text-xs font-medium transition-colors
51
+ {{if (eq this.leftPanelTab 'queries')
52
+ 'text-blue-600 dark:text-blue-400 border-b-2 border-blue-500 -mb-px'
53
+ 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'}}"
54
+ {{on "click" (fn this.setLeftPanelTab "queries")}}
55
+ >
56
+ <FaIcon @icon="database" class="w-3 h-3" />
57
+ <span>Queries</span>
58
+ {{#if this.queries.length}}
59
+ <span class="ml-0.5 px-1 py-0 rounded-full bg-indigo-100 dark:bg-indigo-900/40 text-indigo-600 dark:text-indigo-400 text-xs leading-4">{{this.queries.length}}</span>
60
+ {{/if}}
61
+ </button>
62
+ </div>
63
+
64
+ {{! Panel content }}
65
+ <div class="flex-1 overflow-hidden flex flex-col">
66
+ {{#if (eq this.leftPanelTab "layers")}}
67
+ <TemplateBuilder::LayersPanel
68
+ @elements={{this.elements}}
69
+ @selectedElement={{this.selectedElement}}
70
+ @onSelectElement={{this.selectElement}}
71
+ @onUpdateElement={{this.updateElement}}
72
+ @onDeleteElement={{this.deleteElement}}
73
+ @onReorderElement={{this.reorderElement}}
74
+ />
75
+ {{else}}
76
+ <TemplateBuilder::QueriesPanel
77
+ @queries={{this.queries}}
78
+ @onQueriesChange={{this.handleQueriesChange}}
79
+ />
80
+ {{/if}}
81
+ </div>
82
+ </div>
83
+
84
+ {{! CENTER: Canvas area (scrollable) }}
85
+ <div class="tb-canvas-area flex-1 overflow-auto p-8 flex items-start justify-center">
86
+ <TemplateBuilder::Canvas
87
+ @template={{this.template}}
88
+ @selectedElement={{this.selectedElement}}
89
+ @zoom={{this.zoom}}
90
+ @onSelectElement={{this.selectElement}}
91
+ @onMoveElement={{this.moveElement}}
92
+ @onResizeElement={{this.resizeElement}}
93
+ @onDeselectAll={{this.deselectAll}}
94
+ />
95
+ </div>
96
+
97
+ {{! RIGHT: Properties panel }}
98
+ <div class="tb-panel-right w-64 flex-shrink-0 border-l border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 overflow-hidden flex flex-col">
99
+ <TemplateBuilder::PropertiesPanel
100
+ @selectedElement={{this.selectedElement}}
101
+ @template={{this.template}}
102
+ @contextSchemas={{this.contextSchemas}}
103
+ @onUpdateElement={{this.updateElement}}
104
+ @onUpdateTemplate={{this.updateTemplate}}
105
+ @onOpenVariablePicker={{this.openVariablePicker}}
106
+ />
107
+ </div>
108
+ </div>
109
+
110
+ {{! ================================================================ }}
111
+ {{! VARIABLE PICKER MODAL }}
112
+ {{! ================================================================ }}
113
+ <TemplateBuilder::VariablePicker
114
+ @isOpen={{this.variablePickerOpen}}
115
+ @contextSchemas={{this.enrichedContextSchemas}}
116
+ @onInsert={{this.handleVariableInsert}}
117
+ @onClose={{this.closeVariablePicker}}
118
+ />
119
+ </div>
@@ -0,0 +1,567 @@
1
+ import Component from '@glimmer/component';
2
+ import { tracked } from '@glimmer/tracking';
3
+ import { action } from '@ember/object';
4
+ import { guidFor } from '@ember/object/internals';
5
+ import { inject as service } from '@ember/service';
6
+ import { next } from '@ember/runloop';
7
+
8
+ /**
9
+ * TemplateBuilderComponent
10
+ *
11
+ * The top-level template builder component. Composes the toolbar, layers panel,
12
+ * canvas, and properties panel into a full-screen editor.
13
+ *
14
+ * State architecture
15
+ * ------------------
16
+ * Template-level fields (name, paper_size, orientation, width, height, unit,
17
+ * background_color, …) are stored in `@tracked _meta` — a plain object.
18
+ *
19
+ * The element list is stored in `@tracked _content` — a plain JS array of
20
+ * element objects. This array is the single source of truth for what is
21
+ * rendered in the `{{#each}}` loop on the canvas.
22
+ *
23
+ * The critical invariant is: **existing element objects must keep their JS
24
+ * identity across renders**. Glimmer's `{{#each}}` loop reuses a component
25
+ * instance as long as its item reference is the same object. If the reference
26
+ * changes, Glimmer destroys the old component (firing will-destroy →
27
+ * teardownElement → interactable.unset()) and creates a new one. This is why
28
+ * the previous implementation — which did JSON.parse(JSON.stringify(...)) on
29
+ * the entire content array for every operation — caused all interact.js
30
+ * instances to be destroyed whenever any element was added, deleted, or
31
+ * updated.
32
+ *
33
+ * Rules:
34
+ * addElement → push a new object onto _content; existing objects unchanged
35
+ * moveElement → Object.assign onto the existing object; no _content write
36
+ * updateElement → Object.assign onto the existing object; then replace
37
+ * _content with a new array (same objects) to trigger
38
+ * reactivity for the properties panel and layers panel
39
+ * deleteElement → filter _content to a new array without the target object
40
+ * undo/redo → replace _content with deep-cloned snapshots (full re-render
41
+ * is acceptable here since it is an explicit user action)
42
+ *
43
+ * Usage:
44
+ * <TemplateBuilder
45
+ * @template={{this.template}}
46
+ * @contextSchemas={{this.contextSchemas}}
47
+ * @onSave={{this.saveTemplate}}
48
+ * @onPreview={{this.previewTemplate}}
49
+ * />
50
+ *
51
+ * @argument {Object} template - The template object to edit.
52
+ * Shape: { uuid, name, context_type, paper_size, orientation,
53
+ * width, height, unit, background_color, content: [] }
54
+ * @argument {Array} contextSchemas - Variable schema array from GET /api/v1/templates/context-schemas
55
+ * @argument {Boolean} isSaving - Whether a save is in progress (controls toolbar spinner)
56
+ * @argument {Function} onSave - Called with the updated template object when Save is clicked
57
+ * @argument {Function} onPreview - Called with the current template object when Preview is clicked
58
+ * @argument {Function} [onClose] - Optional. If provided, a close/back button appears in the toolbar.
59
+ * @argument {String} [closeIcon] - FontAwesome icon for the close button (default: 'chevron-left')
60
+ * @argument {String} [closeLabel] - Text label beside the close icon (default: none — icon only)
61
+ */
62
+ export default class TemplateBuilderComponent extends Component {
63
+ @service fetch;
64
+ @service notifications;
65
+
66
+ // -------------------------------------------------------------------------
67
+ // State
68
+ // -------------------------------------------------------------------------
69
+
70
+ /** @type {Object|null} Currently selected element */
71
+ @tracked selectedElement = null;
72
+
73
+ /** @type {Number} Canvas zoom level (1 = 100%) */
74
+ @tracked zoom = 1;
75
+
76
+ /** @type {Boolean} Whether the variable picker modal is open */
77
+ @tracked variablePickerOpen = false;
78
+
79
+ /** @type {String|null} The element property the variable picker is targeting */
80
+ @tracked variablePickerTargetProp = null;
81
+
82
+ /** @type {Function|null} Callback to call with the chosen variable/formula string */
83
+ @tracked variablePickerCallback = null;
84
+
85
+ /** @type {String} Which tab is active in the left panel: 'layers' or 'queries' */
86
+ @tracked leftPanelTab = 'layers';
87
+
88
+ /** @type {Array} TemplateQuery records for the current template */
89
+ @tracked queries = [];
90
+
91
+ /** @type {Array} Undo history stack — each entry is a deep-cloned content snapshot */
92
+ @tracked _undoStack = [];
93
+
94
+ /** @type {Array} Redo history stack */
95
+ @tracked _redoStack = [];
96
+
97
+ /**
98
+ * Non-content template fields (name, paper_size, orientation, width, height,
99
+ * unit, background_color, uuid, context_type, …). Stored separately from
100
+ * _content so that canvas-level changes do not require touching the element
101
+ * array at all.
102
+ * @type {Object}
103
+ */
104
+ @tracked _meta = null;
105
+
106
+ /**
107
+ * The live element array. Each object in this array is mutated in-place
108
+ * during drag/resize/property-update operations. The array reference itself
109
+ * is replaced (to trigger Glimmer reactivity) only when elements are added
110
+ * or deleted.
111
+ * @type {Array}
112
+ */
113
+ @tracked _content = [];
114
+
115
+ // -------------------------------------------------------------------------
116
+ // Lifecycle
117
+ // -------------------------------------------------------------------------
118
+
119
+ constructor(owner, args) {
120
+ super(owner, args);
121
+ const cloned = this._cloneTemplate(args.template);
122
+ const { content, queries, ...meta } = cloned;
123
+ this._meta = meta;
124
+ this._content = Array.isArray(content) ? content : [];
125
+ // Seed queries from the template relationship (loaded via hasMany).
126
+ // When the template is new (unsaved), this will be an empty array.
127
+ this.queries = Array.isArray(queries) ? queries : [];
128
+ }
129
+
130
+ // -------------------------------------------------------------------------
131
+ // Computed
132
+ // -------------------------------------------------------------------------
133
+
134
+ /**
135
+ * Reconstituted full template object — used for save, preview, and passing
136
+ * to child components that need the complete shape (canvas, properties panel).
137
+ */
138
+ get template() {
139
+ // Include queries in the save payload so the backend can upsert them
140
+ // in a single request alongside the template record.
141
+ return { ...this._meta, content: this._content, queries: this.queries };
142
+ }
143
+
144
+ get elements() {
145
+ return this._content;
146
+ }
147
+
148
+ get contextSchemas() {
149
+ return this.args.contextSchemas ?? [];
150
+ }
151
+
152
+ /**
153
+ * Context schemas enriched with a "Queries" section derived from the saved
154
+ * TemplateQuery records. This is passed to the variable picker so users can
155
+ * insert query variable tokens (e.g. {recent_orders}) into element properties.
156
+ */
157
+ get enrichedContextSchemas() {
158
+ const base = this.contextSchemas;
159
+ if (!this.queries.length) return base;
160
+
161
+ const queriesSchema = {
162
+ namespace: '__queries__',
163
+ label: 'Queries',
164
+ icon: 'database',
165
+ variables: this.queries.map((q) => ({
166
+ path: q.variable_name,
167
+ label: q.label,
168
+ type: 'array',
169
+ example: `[{ ... }] (${q.resource_type_label ?? q.model_type ?? ''})`,
170
+ })),
171
+ };
172
+
173
+ return [queriesSchema, ...base];
174
+ }
175
+
176
+ get canUndo() {
177
+ return this._undoStack.length > 0;
178
+ }
179
+
180
+ get canRedo() {
181
+ return this._redoStack.length > 0;
182
+ }
183
+
184
+ // -------------------------------------------------------------------------
185
+ // Element CRUD
186
+ // -------------------------------------------------------------------------
187
+
188
+ @action
189
+ addElement(type) {
190
+ this._pushUndo();
191
+
192
+ const defaults = this._defaultsForType(type);
193
+ const newElement = {
194
+ uuid: guidFor({}),
195
+ type,
196
+ label: null,
197
+ visible: true,
198
+ x: 20,
199
+ y: 20,
200
+ width: defaults.width,
201
+ height: defaults.height,
202
+ z_index: this._content.length + 1,
203
+ rotation: 0,
204
+ opacity: 1,
205
+ ...defaults.props,
206
+ };
207
+
208
+ // Push onto the existing array and replace the reference so Glimmer
209
+ // detects the change. Existing element objects are NOT replaced — only
210
+ // the new element is added. This means Glimmer will create exactly one
211
+ // new ElementRenderer component and leave all existing ones untouched.
212
+ this._content = [...this._content, newElement];
213
+ this.selectedElement = newElement;
214
+ }
215
+
216
+ @action
217
+ selectElement(element) {
218
+ this.selectedElement = element;
219
+ }
220
+
221
+ @action
222
+ deselectAll() {
223
+ this.selectedElement = null;
224
+ }
225
+
226
+ /**
227
+ * Update element properties from the properties panel (or any other source
228
+ * that needs a re-render, e.g. undo-able changes).
229
+ *
230
+ * Creates a NEW element object (spread copy + changes) and replaces the
231
+ * element at its index in _content. This gives @element a new reference,
232
+ * which Glimmer detects and uses to re-render the ElementRenderer component
233
+ * — causing getters like textContent, textStyle, wrapperStyle to re-evaluate.
234
+ *
235
+ * The interact.js instance for the element is torn down (will-destroy fires)
236
+ * and immediately re-created (did-insert fires) with the new object. Since
237
+ * setupElement keys interactables by uuid, this is seamless.
238
+ */
239
+ @action
240
+ updateElement(uuid, changes) {
241
+ this._pushUndo();
242
+
243
+ const index = this._content.findIndex((e) => e.uuid === uuid);
244
+ if (index === -1) return;
245
+
246
+ // Create a NEW object (spread copy + changes) so that Glimmer detects
247
+ // a reference change on @element and re-renders the ElementRenderer.
248
+ // Mutating in-place (Object.assign) keeps the same reference, which
249
+ // Glimmer treats as unchanged — causing getters like textContent,
250
+ // textStyle, wrapperStyle to never re-evaluate after a property update.
251
+ const updated = { ...this._content[index], ...changes };
252
+
253
+ // Replace the element at its index and produce a new array.
254
+ const next = [...this._content];
255
+ next[index] = updated;
256
+ this._content = next;
257
+
258
+ // Sync selectedElement so the properties panel reflects the new values.
259
+ if (this.selectedElement?.uuid === uuid) {
260
+ this.selectedElement = updated;
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Silently sync an element's position after a drag gesture ends.
266
+ * Mutates in-place — no Glimmer re-render, no undo entry.
267
+ * interact.js has already updated the DOM; this just keeps the data model
268
+ * in sync so the correct position is included in the next save.
269
+ */
270
+ @action
271
+ moveElement(uuid, { x, y }) {
272
+ const el = this._content.find((e) => e.uuid === uuid);
273
+ if (!el) return;
274
+ Object.assign(el, { x, y });
275
+ }
276
+
277
+ /**
278
+ * Silently sync an element's position and size after a resize gesture ends.
279
+ * Mutates in-place — no Glimmer re-render, no undo entry.
280
+ * interact.js has already updated the DOM; this just keeps the data model
281
+ * in sync so the correct dimensions are included in the next save.
282
+ */
283
+ @action
284
+ resizeElement(uuid, { x, y, width, height }) {
285
+ const el = this._content.find((e) => e.uuid === uuid);
286
+ if (!el) return;
287
+ Object.assign(el, { x, y, width, height });
288
+ }
289
+
290
+ /**
291
+ * Rotate an element by a delta in degrees (e.g. +90 or -90).
292
+ * Uses updateElement so the change is tracked in undo history and
293
+ * the properties panel rotation input updates immediately.
294
+ */
295
+ @action
296
+ rotateElement(uuid, deltaDegrees) {
297
+ const el = this._content.find((e) => e.uuid === uuid);
298
+ if (!el) return;
299
+ const current = el.rotation ?? 0;
300
+ // Normalise to [0, 360)
301
+ const next = (((current + deltaDegrees) % 360) + 360) % 360;
302
+ this.updateElement(uuid, { rotation: next });
303
+ }
304
+
305
+ @action
306
+ deleteElement(uuid) {
307
+ this._pushUndo();
308
+ this._content = this._content.filter((el) => el.uuid !== uuid);
309
+ if (this.selectedElement?.uuid === uuid) {
310
+ this.selectedElement = null;
311
+ }
312
+ }
313
+
314
+ @action
315
+ reorderElement(uuid, direction) {
316
+ this._pushUndo();
317
+
318
+ const elements = this._content;
319
+ const element = elements.find((el) => el.uuid === uuid);
320
+ if (!element) return;
321
+
322
+ const currentZ = element.z_index ?? 1;
323
+ const sorted = [...elements].sort((a, b) => (a.z_index ?? 0) - (b.z_index ?? 0));
324
+ const sortedIndex = sorted.findIndex((el) => el.uuid === uuid);
325
+
326
+ let swapElement = null;
327
+ if (direction === 'up' && sortedIndex < sorted.length - 1) {
328
+ swapElement = sorted[sortedIndex + 1];
329
+ } else if (direction === 'down' && sortedIndex > 0) {
330
+ swapElement = sorted[sortedIndex - 1];
331
+ }
332
+
333
+ if (!swapElement) return;
334
+
335
+ const swapZ = swapElement.z_index ?? 1;
336
+
337
+ // Replace both objects with new copies (not in-place mutation) so
338
+ // Glimmer detects the reference change and re-renders each ElementRenderer.
339
+ this._content = this._content.map((el) => {
340
+ if (el.uuid === element.uuid) return { ...el, z_index: swapZ };
341
+ if (el.uuid === swapElement.uuid) return { ...el, z_index: currentZ };
342
+ return el;
343
+ });
344
+
345
+ // Sync selectedElement so the properties panel reflects the new z_index.
346
+ if (this.selectedElement?.uuid === uuid) {
347
+ this.selectedElement = this._content.find((el) => el.uuid === uuid) ?? null;
348
+ }
349
+ }
350
+
351
+ // -------------------------------------------------------------------------
352
+ // Template-level updates (non-content fields)
353
+ // -------------------------------------------------------------------------
354
+
355
+ @action
356
+ updateTemplate(changes) {
357
+ this._pushUndo();
358
+ this._updateMeta(changes);
359
+ }
360
+
361
+ // -------------------------------------------------------------------------
362
+ // Zoom
363
+ // -------------------------------------------------------------------------
364
+
365
+ @action
366
+ zoomIn() {
367
+ this.zoom = Math.min(3, parseFloat((this.zoom + 0.1).toFixed(1)));
368
+ }
369
+
370
+ @action
371
+ zoomOut() {
372
+ this.zoom = Math.max(0.25, parseFloat((this.zoom - 0.1).toFixed(1)));
373
+ }
374
+
375
+ @action
376
+ zoomReset() {
377
+ this.zoom = 1;
378
+ }
379
+
380
+ // -------------------------------------------------------------------------
381
+ // Undo / Redo
382
+ // -------------------------------------------------------------------------
383
+
384
+ @action
385
+ undo() {
386
+ if (!this.canUndo) return;
387
+ const stack = [...this._undoStack];
388
+ const snapshot = stack.pop();
389
+ this._undoStack = stack;
390
+ this._redoStack = [...this._redoStack, this._cloneContent(this._content)];
391
+ // Restore from snapshot — new object references, so full re-render.
392
+ // This is acceptable: undo is an explicit user action.
393
+ this._content = snapshot;
394
+ this.selectedElement = null;
395
+ }
396
+
397
+ @action
398
+ redo() {
399
+ if (!this.canRedo) return;
400
+ const stack = [...this._redoStack];
401
+ const snapshot = stack.pop();
402
+ this._redoStack = stack;
403
+ this._undoStack = [...this._undoStack, this._cloneContent(this._content)];
404
+ this._content = snapshot;
405
+ this.selectedElement = null;
406
+ }
407
+
408
+ // -------------------------------------------------------------------------
409
+ // Variable Picker
410
+ // -------------------------------------------------------------------------
411
+
412
+ @action
413
+ openVariablePicker(targetProp, callback) {
414
+ this.variablePickerTargetProp = targetProp;
415
+ this.variablePickerCallback = callback;
416
+ this.variablePickerOpen = true;
417
+ }
418
+
419
+ @action
420
+ closeVariablePicker() {
421
+ this.variablePickerOpen = false;
422
+ this.variablePickerTargetProp = null;
423
+ this.variablePickerCallback = null;
424
+ }
425
+
426
+ @action
427
+ handleVariableInsert(token) {
428
+ if (this.variablePickerCallback) {
429
+ this.variablePickerCallback(token);
430
+ }
431
+ this.closeVariablePicker();
432
+ }
433
+
434
+ // -------------------------------------------------------------------------
435
+ // Left panel tab
436
+ // -------------------------------------------------------------------------
437
+
438
+ @action
439
+ setLeftPanelTab(tab) {
440
+ this.leftPanelTab = tab;
441
+ }
442
+
443
+ // -------------------------------------------------------------------------
444
+ // Queries
445
+ // -------------------------------------------------------------------------
446
+
447
+ /**
448
+ * Called by QueriesPanel whenever the queries list changes (load, add, edit,
449
+ * delete). Updates the local queries array so enrichedContextSchemas stays
450
+ * in sync with the variable picker.
451
+ */
452
+ @action
453
+ handleQueriesChange(queries) {
454
+ this.queries = queries ?? [];
455
+ }
456
+
457
+ // -------------------------------------------------------------------------
458
+ // Save / Preview
459
+ // -------------------------------------------------------------------------
460
+
461
+ @action
462
+ save() {
463
+ if (this.args.onSave) {
464
+ this.args.onSave(this.template);
465
+ }
466
+ }
467
+
468
+ @action
469
+ preview() {
470
+ if (this.args.onPreview) {
471
+ this.args.onPreview(this.template);
472
+ }
473
+ }
474
+
475
+ // -------------------------------------------------------------------------
476
+ // Private helpers
477
+ // -------------------------------------------------------------------------
478
+
479
+ /**
480
+ * Update non-content template fields. Resolves paper_size + orientation
481
+ * into width/height/unit automatically.
482
+ */
483
+ _updateMeta(changes) {
484
+ const merged = Object.assign({}, this._meta, changes);
485
+
486
+ if (changes.paper_size !== undefined || changes.orientation !== undefined) {
487
+ const dims = this._dimensionsForPaperSize(merged.paper_size ?? 'A4', merged.orientation ?? 'portrait');
488
+ if (dims) {
489
+ merged.width = dims.width;
490
+ merged.height = dims.height;
491
+ merged.unit = dims.unit;
492
+ }
493
+ }
494
+
495
+ // Defer to avoid "modified tracked value during render" assertion
496
+ next(this, () => {
497
+ this._meta = merged;
498
+ });
499
+ }
500
+
501
+ /**
502
+ * Returns { width, height, unit } for a given paper size and orientation.
503
+ * Dimensions are in mm (portrait). Landscape swaps width and height.
504
+ */
505
+ _dimensionsForPaperSize(paperSize, orientation) {
506
+ const sizes = {
507
+ A4: { width: 210, height: 297 },
508
+ A3: { width: 297, height: 420 },
509
+ A5: { width: 148, height: 210 },
510
+ Letter: { width: 216, height: 279 },
511
+ Legal: { width: 216, height: 356 },
512
+ };
513
+ const base = sizes[paperSize];
514
+ if (!base) return null;
515
+ const isLandscape = orientation === 'landscape';
516
+ return {
517
+ width: isLandscape ? base.height : base.width,
518
+ height: isLandscape ? base.width : base.height,
519
+ unit: 'mm',
520
+ };
521
+ }
522
+
523
+ _pushUndo() {
524
+ // Replace array references (not mutate) so @tracked detects the change
525
+ // and canUndo/canRedo getters re-evaluate.
526
+ let stack = [...this._undoStack, this._cloneContent(this._content)];
527
+ if (stack.length > 50) {
528
+ stack = stack.slice(stack.length - 50);
529
+ }
530
+ this._undoStack = stack;
531
+ this._redoStack = [];
532
+ }
533
+
534
+ _cloneContent(content) {
535
+ return JSON.parse(JSON.stringify(content ?? []));
536
+ }
537
+
538
+ _cloneTemplate(template) {
539
+ if (!template) return {};
540
+
541
+ const isEmberModel = template && typeof template.eachAttribute === 'function';
542
+ if (isEmberModel) {
543
+ const plain = {};
544
+ template.eachAttribute((name) => {
545
+ const val = template[name];
546
+ plain[name] = val !== null && val !== undefined ? JSON.parse(JSON.stringify(val)) : val;
547
+ });
548
+ plain.uuid = template.uuid ?? template.id ?? null;
549
+ return plain;
550
+ }
551
+
552
+ return JSON.parse(JSON.stringify(template));
553
+ }
554
+
555
+ _defaultsForType(type) {
556
+ const map = {
557
+ text: { width: 200, height: 40, props: { content: 'Text', font_size: 14, font_family: 'Inter, sans-serif', font_weight: '400', color: '#000000', text_align: 'left' } },
558
+ image: { width: 150, height: 100, props: { src: '', alt: '', object_fit: 'cover' } },
559
+ table: { width: 400, height: 200, props: { columns: [], rows: [], border_color: '#e5e7eb', header_background: '#f9fafb', header_color: '#111827', cell_padding: 6 } },
560
+ line: { width: 200, height: 2, props: { color: '#000000', line_width: 1, line_style: 'solid' } },
561
+ shape: { width: 100, height: 100, props: { shape: 'rectangle', background_color: '#e5e7eb', border_width: 0, border_color: '#000000', border_radius: 0 } },
562
+ qr_code: { width: 80, height: 80, props: { value: '' } },
563
+ barcode: { width: 200, height: 60, props: { value: '', barcode_format: 'CODE128' } },
564
+ };
565
+ return map[type] ?? { width: 100, height: 40, props: {} };
566
+ }
567
+ }