@fleetbase/ember-ui 0.3.22 → 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.
- package/addon/components/layout/header/smart-nav-menu/dropdown.hbs +77 -68
- package/addon/components/layout/header/smart-nav-menu/dropdown.js +7 -54
- package/addon/components/template-builder/canvas.hbs +23 -0
- package/addon/components/template-builder/canvas.js +116 -0
- package/addon/components/template-builder/element-renderer.hbs +126 -0
- package/addon/components/template-builder/element-renderer.js +398 -0
- package/addon/components/template-builder/layers-panel.hbs +99 -0
- package/addon/components/template-builder/layers-panel.js +146 -0
- package/addon/components/template-builder/properties-panel/field.hbs +7 -0
- package/addon/components/template-builder/properties-panel/field.js +9 -0
- package/addon/components/template-builder/properties-panel/section.hbs +24 -0
- package/addon/components/template-builder/properties-panel/section.js +19 -0
- package/addon/components/template-builder/properties-panel.hbs +576 -0
- package/addon/components/template-builder/properties-panel.js +413 -0
- package/addon/components/template-builder/queries-panel.hbs +84 -0
- package/addon/components/template-builder/queries-panel.js +88 -0
- package/addon/components/template-builder/query-form.hbs +260 -0
- package/addon/components/template-builder/query-form.js +309 -0
- package/addon/components/template-builder/toolbar.hbs +134 -0
- package/addon/components/template-builder/toolbar.js +106 -0
- package/addon/components/template-builder/variable-picker.hbs +210 -0
- package/addon/components/template-builder/variable-picker.js +181 -0
- package/addon/components/template-builder.hbs +119 -0
- package/addon/components/template-builder.js +567 -0
- package/addon/helpers/string-starts-with.js +14 -0
- package/addon/services/template-builder.js +72 -0
- package/addon/styles/addon.css +1 -0
- package/addon/styles/components/badge.css +66 -12
- package/addon/styles/components/smart-nav-menu.css +35 -29
- package/addon/styles/components/template-builder.css +297 -0
- package/addon/utils/get-currency.js +1 -1
- package/app/components/template-builder/canvas.js +1 -0
- package/app/components/template-builder/element-renderer.js +1 -0
- package/app/components/template-builder/layers-panel.js +1 -0
- package/app/components/template-builder/properties-panel/field.js +1 -0
- package/app/components/template-builder/properties-panel/section.js +1 -0
- package/app/components/template-builder/properties-panel.js +1 -0
- package/app/components/template-builder/queries-panel.js +1 -0
- package/app/components/template-builder/query-form.js +1 -0
- package/app/components/template-builder/toolbar.js +1 -0
- package/app/components/template-builder/variable-picker.js +1 -0
- package/app/components/template-builder.js +1 -0
- package/app/helpers/string-starts-with.js +1 -0
- package/app/services/template-builder.js +1 -0
- package/package.json +3 -2
- 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
|
+
}
|