@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,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,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>
|