@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.
Files changed (46) hide show
  1. package/addon/components/layout/header/smart-nav-menu/dropdown.hbs +77 -68
  2. package/addon/components/layout/header/smart-nav-menu/dropdown.js +7 -54
  3. package/addon/components/template-builder/canvas.hbs +23 -0
  4. package/addon/components/template-builder/canvas.js +116 -0
  5. package/addon/components/template-builder/element-renderer.hbs +126 -0
  6. package/addon/components/template-builder/element-renderer.js +398 -0
  7. package/addon/components/template-builder/layers-panel.hbs +99 -0
  8. package/addon/components/template-builder/layers-panel.js +146 -0
  9. package/addon/components/template-builder/properties-panel/field.hbs +7 -0
  10. package/addon/components/template-builder/properties-panel/field.js +9 -0
  11. package/addon/components/template-builder/properties-panel/section.hbs +24 -0
  12. package/addon/components/template-builder/properties-panel/section.js +19 -0
  13. package/addon/components/template-builder/properties-panel.hbs +576 -0
  14. package/addon/components/template-builder/properties-panel.js +413 -0
  15. package/addon/components/template-builder/queries-panel.hbs +84 -0
  16. package/addon/components/template-builder/queries-panel.js +88 -0
  17. package/addon/components/template-builder/query-form.hbs +260 -0
  18. package/addon/components/template-builder/query-form.js +309 -0
  19. package/addon/components/template-builder/toolbar.hbs +134 -0
  20. package/addon/components/template-builder/toolbar.js +106 -0
  21. package/addon/components/template-builder/variable-picker.hbs +210 -0
  22. package/addon/components/template-builder/variable-picker.js +181 -0
  23. package/addon/components/template-builder.hbs +119 -0
  24. package/addon/components/template-builder.js +567 -0
  25. package/addon/helpers/string-starts-with.js +14 -0
  26. package/addon/services/template-builder.js +72 -0
  27. package/addon/styles/addon.css +1 -0
  28. package/addon/styles/components/badge.css +66 -12
  29. package/addon/styles/components/smart-nav-menu.css +35 -29
  30. package/addon/styles/components/template-builder.css +297 -0
  31. package/addon/utils/get-currency.js +1 -1
  32. package/app/components/template-builder/canvas.js +1 -0
  33. package/app/components/template-builder/element-renderer.js +1 -0
  34. package/app/components/template-builder/layers-panel.js +1 -0
  35. package/app/components/template-builder/properties-panel/field.js +1 -0
  36. package/app/components/template-builder/properties-panel/section.js +1 -0
  37. package/app/components/template-builder/properties-panel.js +1 -0
  38. package/app/components/template-builder/queries-panel.js +1 -0
  39. package/app/components/template-builder/query-form.js +1 -0
  40. package/app/components/template-builder/toolbar.js +1 -0
  41. package/app/components/template-builder/variable-picker.js +1 -0
  42. package/app/components/template-builder.js +1 -0
  43. package/app/helpers/string-starts-with.js +1 -0
  44. package/app/services/template-builder.js +1 -0
  45. package/package.json +3 -2
  46. package/tsconfig.declarations.json +8 -8
@@ -0,0 +1,398 @@
1
+ import Component from '@glimmer/component';
2
+ import { action } from '@ember/object';
3
+ import interact from 'interactjs';
4
+
5
+ /**
6
+ * TemplateBuilderElementRendererComponent
7
+ *
8
+ * Renders a single template element on the canvas and owns all interaction
9
+ * behaviour for that element: drag, resize, and selection.
10
+ *
11
+ * Responsibilities
12
+ * ----------------
13
+ * - Sets up and tears down its own interact.js instance in did-insert /
14
+ * will-destroy. No parent component needs to know about interact.js.
15
+ * - Emits @onMove(uuid, { x, y }) when a drag gesture ends.
16
+ * - Emits @onResize(uuid, { x, y, width, height }) when a resize gesture ends.
17
+ * - Emits @onSelect(element) when the element is tapped/clicked.
18
+ *
19
+ * Positioning strategy
20
+ * --------------------
21
+ * The wrapper div is placed at `left: 0; top: 0` and positioned exclusively
22
+ * via `transform: translate(x, y)`. interact.js updates this transform
23
+ * imperatively during drag/resize. Glimmer's wrapperStyle deliberately omits
24
+ * the transform so Glimmer re-renders never overwrite interact.js's live values.
25
+ *
26
+ * Canvas boundary clamping
27
+ * ------------------------
28
+ * @canvasWidth and @canvasHeight (in unscaled template pixels) are passed from
29
+ * the canvas so the element cannot be dragged or resized outside the canvas.
30
+ * @zoom is used to convert interact.js screen-pixel deltas to template pixels.
31
+ *
32
+ * @argument {Object} element - The element data object from template.content
33
+ * @argument {Boolean} isSelected - Whether this element is currently selected
34
+ * @argument {Number} zoom - Canvas zoom level (1 = 100%)
35
+ * @argument {Number} canvasWidth - Canvas width in unscaled template pixels
36
+ * @argument {Number} canvasHeight - Canvas height in unscaled template pixels
37
+ * @argument {Function} onSelect - Called with (element) when element is tapped
38
+ * @argument {Function} onMove - Called with (uuid, { x, y }) after drag ends
39
+ * @argument {Function} onResize - Called with (uuid, { x, y, width, height }) after resize ends
40
+ */
41
+ export default class TemplateBuilderElementRendererComponent extends Component {
42
+ /** @type {import('interactjs').Interactable|null} */
43
+ _interactable = null;
44
+
45
+ // -------------------------------------------------------------------------
46
+ // Lifecycle
47
+ // -------------------------------------------------------------------------
48
+
49
+ @action
50
+ handleInsert(el) {
51
+ // Seed position data-attributes from the element data model.
52
+ el.dataset.x = this.args.element.x ?? 0;
53
+ el.dataset.y = this.args.element.y ?? 0;
54
+
55
+ // Apply the initial CSS transform so the element appears at the correct
56
+ // position immediately, before any interact.js event fires.
57
+ this._applyTransform(el);
58
+
59
+ // Set up interact.js on this element's DOM node.
60
+ this._setupInteract(el);
61
+ }
62
+
63
+ @action
64
+ handleUpdate(el) {
65
+ // Glimmer has just re-rendered the style attribute (e.g. after a z_index
66
+ // change via reorderElement). The style attribute does not include the
67
+ // CSS transform — that is managed imperatively by interact.js and
68
+ // _applyTransform. Re-apply it now so the element stays at its current
69
+ // position rather than snapping to 0,0.
70
+ this._applyTransform(el);
71
+ }
72
+
73
+ @action
74
+ handleDestroy() {
75
+ if (this._interactable) {
76
+ try {
77
+ this._interactable.unset();
78
+ } catch (_) {
79
+ // ignore
80
+ }
81
+ this._interactable = null;
82
+ }
83
+ }
84
+
85
+ // -------------------------------------------------------------------------
86
+ // Interaction setup
87
+ // -------------------------------------------------------------------------
88
+
89
+ _setupInteract(el) {
90
+ // ── Helpers ────────────────────────────────────────────────────────────
91
+
92
+ const getPos = () => ({
93
+ x: parseFloat(el.dataset.x) || 0,
94
+ y: parseFloat(el.dataset.y) || 0,
95
+ });
96
+
97
+ const applyTransform = (x, y) => {
98
+ // Read rotation from the data-attribute so it stays current even
99
+ // after property-panel updates (which re-render @element but do not
100
+ // recreate the interact.js instance).
101
+ const rotation = parseFloat(el.dataset.rotation) || 0;
102
+ el.style.transform = rotation ? `translate(${x}px, ${y}px) rotate(${rotation}deg)` : `translate(${x}px, ${y}px)`;
103
+ el.dataset.x = x;
104
+ el.dataset.y = y;
105
+ };
106
+
107
+ // Read zoom and canvas dimensions at event time so changes are reflected
108
+ // without needing to recreate the interactable.
109
+ const getZoom = () => this.args.zoom ?? 1;
110
+ const getCanvas = () => ({
111
+ w: this.args.canvasWidth ?? Infinity,
112
+ h: this.args.canvasHeight ?? Infinity,
113
+ });
114
+
115
+ // ── Interactable ───────────────────────────────────────────────────────
116
+ this._interactable = interact(el)
117
+ // Tap fires for clicks/taps even when interact.js has consumed the
118
+ // underlying pointer events for drag detection.
119
+ .on('tap', (event) => {
120
+ event.stopPropagation();
121
+ if (this.args.onSelect) {
122
+ this.args.onSelect(this.args.element);
123
+ }
124
+ })
125
+
126
+ // ── Drag ──────────────────────────────────────────────────────────
127
+ .draggable({
128
+ listeners: {
129
+ move: (event) => {
130
+ const zoom = getZoom();
131
+ const canvas = getCanvas();
132
+ const pos = getPos();
133
+
134
+ let x = pos.x + event.dx / zoom;
135
+ let y = pos.y + event.dy / zoom;
136
+
137
+ // Clamp so the element cannot leave the canvas.
138
+ const elW = parseFloat(el.style.width) || (this.args.element.width ?? 100);
139
+ const elH = parseFloat(el.style.height) || (this.args.element.height ?? 30);
140
+ x = Math.max(0, Math.min(x, canvas.w - elW));
141
+ y = Math.max(0, Math.min(y, canvas.h - elH));
142
+
143
+ applyTransform(x, y);
144
+ },
145
+ end: () => {
146
+ const pos = getPos();
147
+ if (this.args.onMove) {
148
+ this.args.onMove(this.args.element.uuid, {
149
+ x: Math.round(pos.x),
150
+ y: Math.round(pos.y),
151
+ });
152
+ }
153
+ },
154
+ },
155
+ })
156
+
157
+ // ── Resize ────────────────────────────────────────────────────────
158
+ .resizable({
159
+ edges: {
160
+ top: '.tb-handle-nw, .tb-handle-ne',
161
+ left: '.tb-handle-nw, .tb-handle-sw',
162
+ bottom: '.tb-handle-sw, .tb-handle-se',
163
+ right: '.tb-handle-ne, .tb-handle-se',
164
+ },
165
+ listeners: {
166
+ move: (event) => {
167
+ const zoom = getZoom();
168
+ const canvas = getCanvas();
169
+ const pos = getPos();
170
+
171
+ let x = pos.x + event.deltaRect.left / zoom;
172
+ let y = pos.y + event.deltaRect.top / zoom;
173
+ let w = event.rect.width / zoom;
174
+ let h = event.rect.height / zoom;
175
+
176
+ w = Math.max(20, w);
177
+ h = Math.max(10, h);
178
+
179
+ x = Math.max(0, Math.min(x, canvas.w - w));
180
+ y = Math.max(0, Math.min(y, canvas.h - h));
181
+
182
+ el.style.width = `${w}px`;
183
+ el.style.height = `${h}px`;
184
+ applyTransform(x, y);
185
+ },
186
+ end: (event) => {
187
+ const zoom = getZoom();
188
+ const pos = getPos();
189
+ const w = Math.max(20, event.rect.width / zoom);
190
+ const h = Math.max(10, event.rect.height / zoom);
191
+ if (this.args.onResize) {
192
+ this.args.onResize(this.args.element.uuid, {
193
+ x: Math.round(pos.x),
194
+ y: Math.round(pos.y),
195
+ width: Math.round(w),
196
+ height: Math.round(h),
197
+ });
198
+ }
199
+ },
200
+ },
201
+ });
202
+ }
203
+
204
+ // -------------------------------------------------------------------------
205
+ // Transform helper
206
+ // -------------------------------------------------------------------------
207
+
208
+ /**
209
+ * Apply the full CSS transform to a DOM element, reading x/y from its
210
+ * data-attributes (kept current by interact.js) and rotation from the
211
+ * element data model. Also writes data-rotation so the interact.js closure
212
+ * can read the current rotation without a stale object reference.
213
+ */
214
+ _applyTransform(el) {
215
+ const x = parseFloat(el.dataset.x) || 0;
216
+ const y = parseFloat(el.dataset.y) || 0;
217
+ const rotation = this.args.element.rotation ?? 0;
218
+ el.dataset.rotation = rotation;
219
+ el.style.transform = rotation ? `translate(${x}px, ${y}px) rotate(${rotation}deg)` : `translate(${x}px, ${y}px)`;
220
+ }
221
+
222
+ // -------------------------------------------------------------------------
223
+ // Computed styles and content
224
+ // -------------------------------------------------------------------------
225
+
226
+ get wrapperStyle() {
227
+ const el = this.args.element;
228
+ const parts = [
229
+ `position: absolute`,
230
+ `left: 0`,
231
+ `top: 0`,
232
+ `width: ${el.width ?? 100}px`,
233
+ `height: ${el.height ?? 30}px`,
234
+ `z-index: ${el.z_index ?? 1}`,
235
+ `box-sizing: border-box`,
236
+ `cursor: move`,
237
+ ];
238
+ if (el.opacity !== undefined && el.opacity !== null) {
239
+ parts.push(`opacity: ${el.opacity}`);
240
+ }
241
+ // transform is intentionally omitted — it is managed imperatively by
242
+ // handleInsert and interact.js so Glimmer re-renders never overwrite it.
243
+ return parts.join('; ');
244
+ }
245
+
246
+ get selectionClass() {
247
+ return this.args.isSelected ? 'ring-2 ring-blue-500 ring-offset-0' : 'hover:ring-1 hover:ring-blue-300 hover:ring-offset-0';
248
+ }
249
+
250
+ get elementType() {
251
+ return this.args.element?.type ?? 'text';
252
+ }
253
+
254
+ get isText() {
255
+ return this.elementType === 'text';
256
+ }
257
+
258
+ get isImage() {
259
+ return this.elementType === 'image';
260
+ }
261
+
262
+ get isTable() {
263
+ return this.elementType === 'table';
264
+ }
265
+
266
+ get isLine() {
267
+ return this.elementType === 'line';
268
+ }
269
+
270
+ get isShape() {
271
+ return this.elementType === 'shape';
272
+ }
273
+
274
+ get isQrCode() {
275
+ return this.elementType === 'qr_code';
276
+ }
277
+
278
+ get isBarcode() {
279
+ return this.elementType === 'barcode';
280
+ }
281
+
282
+ // ── Text ──────────────────────────────────────────────────────────────────
283
+
284
+ get textStyle() {
285
+ const el = this.args.element;
286
+ const styles = this._baseContentStyles(el);
287
+ if (el.font_size) styles.push(`font-size: ${el.font_size}px`);
288
+ if (el.font_family) styles.push(`font-family: ${el.font_family}`);
289
+ if (el.font_weight) styles.push(`font-weight: ${el.font_weight}`);
290
+ if (el.font_style) styles.push(`font-style: ${el.font_style}`);
291
+ if (el.text_align) styles.push(`text-align: ${el.text_align}`);
292
+ if (el.text_decoration) styles.push(`text-decoration: ${el.text_decoration}`);
293
+ if (el.line_height) styles.push(`line-height: ${el.line_height}`);
294
+ if (el.letter_spacing) styles.push(`letter-spacing: ${el.letter_spacing}px`);
295
+ if (el.color) styles.push(`color: ${el.color}`);
296
+ if (el.background_color) styles.push(`background-color: ${el.background_color}`);
297
+ if (el.padding) styles.push(`padding: ${el.padding}px`);
298
+ styles.push(`width: 100%; height: 100%; overflow: hidden; word-wrap: break-word;`);
299
+ return styles.join('; ');
300
+ }
301
+
302
+ get textContent() {
303
+ return this.args.element?.content ?? '';
304
+ }
305
+
306
+ // ── Image ─────────────────────────────────────────────────────────────────
307
+
308
+ get imageStyle() {
309
+ const el = this.args.element;
310
+ const styles = [`width: 100%; height: 100%; display: block;`];
311
+ if (el.object_fit) styles.push(`object-fit: ${el.object_fit}`);
312
+ if (el.border_radius) styles.push(`border-radius: ${el.border_radius}px`);
313
+ return styles.join('; ');
314
+ }
315
+
316
+ get imageSrc() {
317
+ return this.args.element?.src ?? '';
318
+ }
319
+
320
+ get imageAlt() {
321
+ return this.args.element?.alt ?? '';
322
+ }
323
+
324
+ // ── Table ─────────────────────────────────────────────────────────────────
325
+
326
+ get tableColumns() {
327
+ return this.args.element?.columns ?? [];
328
+ }
329
+
330
+ get tableRows() {
331
+ return this.args.element?.rows ?? [];
332
+ }
333
+
334
+ get tableBorderStyle() {
335
+ const color = this.args.element?.border_color;
336
+ return color ? `border-color: ${color}` : '';
337
+ }
338
+
339
+ get tableHeaderStyle() {
340
+ const el = this.args.element;
341
+ const styles = [];
342
+ if (el.header_background) styles.push(`background-color: ${el.header_background}`);
343
+ if (el.header_color) styles.push(`color: ${el.header_color}`);
344
+ if (el.header_font_size) styles.push(`font-size: ${el.header_font_size}px`);
345
+ if (el.header_font_weight) styles.push(`font-weight: ${el.header_font_weight}`);
346
+ return styles.join('; ');
347
+ }
348
+
349
+ get tableCellStyle() {
350
+ const el = this.args.element;
351
+ const styles = [];
352
+ if (el.cell_padding) styles.push(`padding: ${el.cell_padding}px`);
353
+ if (el.cell_font_size) styles.push(`font-size: ${el.cell_font_size}px`);
354
+ if (el.border_color) styles.push(`border-color: ${el.border_color}`);
355
+ return styles.join('; ');
356
+ }
357
+
358
+ // ── Line ──────────────────────────────────────────────────────────────────
359
+
360
+ get lineStyle() {
361
+ return `width: 100%; height: 100%; display: flex; align-items: center;`;
362
+ }
363
+
364
+ get lineInnerStyle() {
365
+ const el = this.args.element;
366
+ return `width: 100%; border-top: ${el.line_width ?? 1}px ${el.line_style ?? 'solid'} ${el.color ?? '#000000'}`;
367
+ }
368
+
369
+ // ── Shape ─────────────────────────────────────────────────────────────────
370
+
371
+ get shapeStyle() {
372
+ const el = this.args.element;
373
+ const styles = [`width: 100%; height: 100%;`];
374
+ if (el.background_color) styles.push(`background-color: ${el.background_color}`);
375
+ if (el.border_width) styles.push(`border: ${el.border_width}px ${el.border_style ?? 'solid'} ${el.border_color ?? '#000000'}`);
376
+ if (el.border_radius) styles.push(`border-radius: ${el.border_radius}px`);
377
+ if (el.shape === 'circle') styles.push(`border-radius: 50%`);
378
+ return styles.join('; ');
379
+ }
380
+
381
+ // ── QR / Barcode ──────────────────────────────────────────────────────────
382
+
383
+ get codeLabel() {
384
+ const el = this.args.element;
385
+ return el.value ?? (this.isQrCode ? 'QR Code' : 'Barcode');
386
+ }
387
+
388
+ // ── Private helpers ───────────────────────────────────────────────────────
389
+
390
+ _baseContentStyles(el) {
391
+ const styles = [];
392
+ if (el.border_width) {
393
+ styles.push(`border: ${el.border_width}px ${el.border_style ?? 'solid'} ${el.border_color ?? '#000000'}`);
394
+ }
395
+ if (el.border_radius) styles.push(`border-radius: ${el.border_radius}px`);
396
+ return styles;
397
+ }
398
+ }
@@ -0,0 +1,99 @@
1
+ {{! Template Builder Layers Panel }}
2
+ <div class="tb-layers-panel flex flex-col h-full" ...attributes>
3
+ {{! Header }}
4
+ <div class="flex items-center justify-between px-3 py-2 border-b border-gray-200 dark:border-gray-700">
5
+ <span class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">Layers</span>
6
+ <span class="text-xs text-gray-400 dark:text-gray-500">{{this.sortedElements.length}}</span>
7
+ </div>
8
+
9
+ {{! Element list }}
10
+ <div class="flex-1 overflow-y-auto">
11
+ {{#if this.sortedElements.length}}
12
+ {{#each this.sortedElements as |element|}}
13
+ {{! Outer row is a div so that real <button> children are valid. }}
14
+ <div
15
+ class="tb-layer-row group flex items-center px-2 py-1.5 cursor-pointer border-b border-transparent hover:bg-gray-50 dark:hover:bg-gray-800 {{if (this.isSelected element) 'bg-blue-50 dark:bg-blue-900/20 border-l-2 border-l-blue-500'}}"
16
+ {{on "click" (fn this.selectElement element)}}
17
+ >
18
+ {{! Type icon }}
19
+ <FaIcon
20
+ @icon={{this.elementIcon element.type}}
21
+ class="w-3 h-3 mr-2 flex-shrink-0 {{if (this.isSelected element) 'text-blue-500' 'text-gray-400 dark:text-gray-500'}}"
22
+ />
23
+
24
+ {{! Label / rename input }}
25
+ <div class="flex-1 min-w-0 mr-1">
26
+ {{#if (this.isRenaming element)}}
27
+ <input
28
+ type="text"
29
+ class="w-full text-xs bg-white dark:bg-gray-700 border border-blue-400 rounded px-1 py-0 outline-none"
30
+ value={{this.renameValue}}
31
+ {{on "input" (fn (mut this.renameValue) value="target.value")}}
32
+ {{on "keydown" (fn this.handleRenameKeydown element)}}
33
+ {{on "blur" (fn this.commitRename element)}}
34
+ {{did-insert (fn (mut this.renameValue) this.renameValue)}}
35
+ />
36
+ {{else}}
37
+ <span
38
+ class="block text-xs truncate {{if (this.isSelected element) 'text-blue-700 dark:text-blue-300 font-medium' 'text-gray-700 dark:text-gray-300'}} {{unless (this.isVisible element) 'opacity-40'}}"
39
+ {{on "dblclick" (fn this.startRename element)}}
40
+ title="Double-click to rename"
41
+ >
42
+ {{this.elementLabel element}}
43
+ </span>
44
+ {{/if}}
45
+ </div>
46
+
47
+ {{! Action buttons (shown on hover or when selected) }}
48
+ <div class="flex items-center space-x-0.5 opacity-0 group-hover:opacity-100 {{if (this.isSelected element) 'opacity-100'}} transition-opacity">
49
+ {{! Visibility toggle }}
50
+ <button
51
+ type="button"
52
+ class="p-0.5 rounded hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
53
+ title={{if (this.isVisible element) "Hide element" "Show element"}}
54
+ {{on "click" (fn this.toggleVisibility element)}}
55
+ >
56
+ <FaIcon @icon={{if (this.isVisible element) "eye" "eye-slash"}} class="w-3 h-3" />
57
+ </button>
58
+
59
+ {{! Move up }}
60
+ <button
61
+ type="button"
62
+ class="p-0.5 rounded hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
63
+ title="Move layer up"
64
+ {{on "click" (fn this.moveUp element)}}
65
+ >
66
+ <FaIcon @icon="chevron-up" class="w-3 h-3" />
67
+ </button>
68
+
69
+ {{! Move down }}
70
+ <button
71
+ type="button"
72
+ class="p-0.5 rounded hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
73
+ title="Move layer down"
74
+ {{on "click" (fn this.moveDown element)}}
75
+ >
76
+ <FaIcon @icon="chevron-down" class="w-3 h-3" />
77
+ </button>
78
+
79
+ {{! Delete }}
80
+ <button
81
+ type="button"
82
+ class="p-0.5 rounded hover:bg-red-100 dark:hover:bg-red-900/30 text-gray-400 dark:text-gray-500 hover:text-red-500 dark:hover:text-red-400"
83
+ title="Delete element"
84
+ {{on "click" (fn this.deleteElement element)}}
85
+ >
86
+ <FaIcon @icon="trash" class="w-3 h-3" />
87
+ </button>
88
+ </div>
89
+ </div>
90
+ {{/each}}
91
+ {{else}}
92
+ <div class="flex flex-col items-center justify-center py-8 px-4 text-center">
93
+ <FaIcon @icon="layer-group" class="w-6 h-6 text-gray-300 dark:text-gray-600 mb-2" />
94
+ <p class="text-xs text-gray-400 dark:text-gray-500">No elements yet.</p>
95
+ <p class="text-xs text-gray-400 dark:text-gray-500 mt-0.5">Use the toolbar to add elements.</p>
96
+ </div>
97
+ {{/if}}
98
+ </div>
99
+ </div>
@@ -0,0 +1,146 @@
1
+ import Component from '@glimmer/component';
2
+ import { tracked } from '@glimmer/tracking';
3
+ import { action } from '@ember/object';
4
+
5
+ /**
6
+ * TemplateBuilderLayersPanelComponent
7
+ *
8
+ * Left panel showing the element tree (layers). Supports:
9
+ * - Selecting an element by clicking its row
10
+ * - Toggling element visibility
11
+ * - Deleting an element
12
+ * - Reordering via z_index controls (move up/down)
13
+ * - Renaming an element label
14
+ *
15
+ * @argument {Array} elements - The template content array (elements)
16
+ * @argument {Object} selectedElement - Currently selected element
17
+ * @argument {Function} onSelectElement - Called with element when row is clicked
18
+ * @argument {Function} onUpdateElement - Called with (uuid, changes) to update an element
19
+ * @argument {Function} onDeleteElement - Called with uuid to delete an element
20
+ * @argument {Function} onReorderElement - Called with (uuid, direction) — 'up' or 'down'
21
+ */
22
+ export default class TemplateBuilderLayersPanelComponent extends Component {
23
+ @tracked renamingUuid = null;
24
+ @tracked renameValue = '';
25
+
26
+ get sortedElements() {
27
+ const elements = this.args.elements ?? [];
28
+ // Sort descending by z_index so highest layer is at top of list (like Figma/Sketch)
29
+ return [...elements].sort((a, b) => (b.z_index ?? 1) - (a.z_index ?? 1));
30
+ }
31
+
32
+ @action
33
+ elementIcon(type) {
34
+ const icons = {
35
+ text: 'font',
36
+ image: 'image',
37
+ table: 'table',
38
+ line: 'minus',
39
+ shape: 'square',
40
+ qr_code: 'qrcode',
41
+ barcode: 'barcode',
42
+ };
43
+ return icons[type] ?? 'layer-group';
44
+ }
45
+
46
+ @action
47
+ elementLabel(element) {
48
+ if (element.label) return element.label;
49
+ const typeLabels = {
50
+ text: 'Text',
51
+ image: 'Image',
52
+ table: 'Table',
53
+ line: 'Line',
54
+ shape: 'Shape',
55
+ qr_code: 'QR Code',
56
+ barcode: 'Barcode',
57
+ };
58
+ return typeLabels[element.type] ?? 'Element';
59
+ }
60
+
61
+ @action
62
+ isSelected(element) {
63
+ return this.args.selectedElement?.uuid === element.uuid;
64
+ }
65
+
66
+ @action
67
+ isVisible(element) {
68
+ return element.visible !== false;
69
+ }
70
+
71
+ @action
72
+ isRenaming(element) {
73
+ return this.renamingUuid === element.uuid;
74
+ }
75
+
76
+ @action
77
+ selectElement(element, event) {
78
+ event.stopPropagation();
79
+ if (this.args.onSelectElement) {
80
+ this.args.onSelectElement(element);
81
+ }
82
+ }
83
+
84
+ @action
85
+ toggleVisibility(element, event) {
86
+ event.stopPropagation();
87
+ if (this.args.onUpdateElement) {
88
+ this.args.onUpdateElement(element.uuid, { visible: !this.isVisible(element) });
89
+ }
90
+ }
91
+
92
+ @action
93
+ deleteElement(element, event) {
94
+ event.stopPropagation();
95
+ if (this.args.onDeleteElement) {
96
+ this.args.onDeleteElement(element.uuid);
97
+ }
98
+ }
99
+
100
+ @action
101
+ moveUp(element, event) {
102
+ event.stopPropagation();
103
+ if (this.args.onReorderElement) {
104
+ this.args.onReorderElement(element.uuid, 'up');
105
+ }
106
+ }
107
+
108
+ @action
109
+ moveDown(element, event) {
110
+ event.stopPropagation();
111
+ if (this.args.onReorderElement) {
112
+ this.args.onReorderElement(element.uuid, 'down');
113
+ }
114
+ }
115
+
116
+ @action
117
+ startRename(element, event) {
118
+ event.stopPropagation();
119
+ this.renamingUuid = element.uuid;
120
+ this.renameValue = this.elementLabel(element);
121
+ }
122
+
123
+ @action
124
+ commitRename(element) {
125
+ if (this.renameValue.trim() && this.args.onUpdateElement) {
126
+ this.args.onUpdateElement(element.uuid, { label: this.renameValue.trim() });
127
+ }
128
+ this.renamingUuid = null;
129
+ this.renameValue = '';
130
+ }
131
+
132
+ @action
133
+ cancelRename() {
134
+ this.renamingUuid = null;
135
+ this.renameValue = '';
136
+ }
137
+
138
+ @action
139
+ handleRenameKeydown(element, event) {
140
+ if (event.key === 'Enter') {
141
+ this.commitRename(element);
142
+ } else if (event.key === 'Escape') {
143
+ this.cancelRename();
144
+ }
145
+ }
146
+ }
@@ -0,0 +1,7 @@
1
+ {{! Properties Panel Field }}
2
+ <div class="tb-prop-field {{if (eq @span '2') 'col-span-2'}}">
3
+ {{#if @label}}
4
+ <label class="block text-xs text-gray-500 dark:text-gray-400 mb-0.5">{{@label}}</label>
5
+ {{/if}}
6
+ {{yield}}
7
+ </div>
@@ -0,0 +1,9 @@
1
+ import Component from '@glimmer/component';
2
+
3
+ /**
4
+ * A labelled field row in the properties panel.
5
+ *
6
+ * @argument {String} label - Field label
7
+ * @argument {String} span - Optional: '2' for full-width (grid col-span-2)
8
+ */
9
+ export default class TemplateBuilderPropertiesPanelFieldComponent extends Component {}
@@ -0,0 +1,24 @@
1
+ {{! Properties Panel Section }}
2
+ <div class="tb-prop-section border-b border-gray-100 dark:border-gray-800">
3
+ <button
4
+ type="button"
5
+ class="w-full flex items-center justify-between px-3 py-2 text-left hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
6
+ {{on "click" this.toggle}}
7
+ >
8
+ <div class="flex items-center space-x-2">
9
+ {{#if @icon}}
10
+ <FaIcon @icon={{@icon}} class="w-3 h-3 text-gray-400 dark:text-gray-500" />
11
+ {{/if}}
12
+ <span class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">{{@title}}</span>
13
+ </div>
14
+ <FaIcon
15
+ @icon={{if @isOpen "chevron-up" "chevron-down"}}
16
+ class="w-3 h-3 text-gray-400 dark:text-gray-500 transition-transform"
17
+ />
18
+ </button>
19
+ {{#if @isOpen}}
20
+ <div class="px-3 pb-3 pt-2">
21
+ {{yield}}
22
+ </div>
23
+ {{/if}}
24
+ </div>