@fleetbase/ember-ui 0.3.23 → 0.3.25

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 (44) hide show
  1. package/addon/components/layout/header/smart-nav-menu.js +7 -1
  2. package/addon/components/template-builder/canvas.hbs +23 -0
  3. package/addon/components/template-builder/canvas.js +116 -0
  4. package/addon/components/template-builder/element-renderer.hbs +126 -0
  5. package/addon/components/template-builder/element-renderer.js +398 -0
  6. package/addon/components/template-builder/layers-panel.hbs +99 -0
  7. package/addon/components/template-builder/layers-panel.js +146 -0
  8. package/addon/components/template-builder/properties-panel/field.hbs +7 -0
  9. package/addon/components/template-builder/properties-panel/field.js +9 -0
  10. package/addon/components/template-builder/properties-panel/section.hbs +24 -0
  11. package/addon/components/template-builder/properties-panel/section.js +19 -0
  12. package/addon/components/template-builder/properties-panel.hbs +576 -0
  13. package/addon/components/template-builder/properties-panel.js +413 -0
  14. package/addon/components/template-builder/queries-panel.hbs +84 -0
  15. package/addon/components/template-builder/queries-panel.js +88 -0
  16. package/addon/components/template-builder/query-form.hbs +260 -0
  17. package/addon/components/template-builder/query-form.js +309 -0
  18. package/addon/components/template-builder/toolbar.hbs +134 -0
  19. package/addon/components/template-builder/toolbar.js +106 -0
  20. package/addon/components/template-builder/variable-picker.hbs +210 -0
  21. package/addon/components/template-builder/variable-picker.js +181 -0
  22. package/addon/components/template-builder.hbs +119 -0
  23. package/addon/components/template-builder.js +567 -0
  24. package/addon/helpers/string-starts-with.js +14 -0
  25. package/addon/services/template-builder.js +72 -0
  26. package/addon/styles/addon.css +1 -0
  27. package/addon/styles/components/badge.css +66 -12
  28. package/addon/styles/components/template-builder.css +297 -0
  29. package/addon/utils/get-currency.js +1 -1
  30. package/app/components/template-builder/canvas.js +1 -0
  31. package/app/components/template-builder/element-renderer.js +1 -0
  32. package/app/components/template-builder/layers-panel.js +1 -0
  33. package/app/components/template-builder/properties-panel/field.js +1 -0
  34. package/app/components/template-builder/properties-panel/section.js +1 -0
  35. package/app/components/template-builder/properties-panel.js +1 -0
  36. package/app/components/template-builder/queries-panel.js +1 -0
  37. package/app/components/template-builder/query-form.js +1 -0
  38. package/app/components/template-builder/toolbar.js +1 -0
  39. package/app/components/template-builder/variable-picker.js +1 -0
  40. package/app/components/template-builder.js +1 -0
  41. package/app/helpers/string-starts-with.js +1 -0
  42. package/app/services/template-builder.js +1 -0
  43. package/package.json +3 -2
  44. package/tsconfig.declarations.json +8 -8
@@ -161,8 +161,14 @@ export default class LayoutHeaderSmartNavMenuComponent extends Component {
161
161
  const raw = this.universe.headerMenuItems ?? [];
162
162
  const visible = [];
163
163
  for (const item of raw) {
164
+ // Shortcuts are not standalone extensions — they should be visible
165
+ // if and only if their parent extension is visible. Use _parentId
166
+ // for the ability check so the shortcut inherits the parent's
167
+ // permission rather than being checked against its own (non-existent)
168
+ // extension ability, which would always throw and default to visible.
169
+ const abilityId = item._isShortcut && item._parentId ? item._parentId : item.id;
164
170
  try {
165
- if (this.abilities.can(`${item.id} see extension`)) {
171
+ if (this.abilities.can(`${abilityId} see extension`)) {
166
172
  visible.push(item);
167
173
  }
168
174
  } catch (_) {
@@ -0,0 +1,23 @@
1
+ {{! Template Builder Canvas }}
2
+ {{! This component is a thin rendering shell. Each ElementRenderer owns its
3
+ own interact.js instance and emits @onMove / @onResize / @onSelect. }}
4
+ <div
5
+ id={{this.canvasId}}
6
+ class="tb-canvas shadow-xl mx-auto select-none"
7
+ style={{this.canvasStyle}}
8
+ {{on "click" this.handleDeselectAll}}
9
+ ...attributes
10
+ >
11
+ {{#each this.elements key="uuid" as |element|}}
12
+ <TemplateBuilder::ElementRenderer
13
+ @element={{element}}
14
+ @isSelected={{eq element.uuid this.selectedUuid}}
15
+ @zoom={{this.zoom}}
16
+ @canvasWidth={{this.canvasWidthPx}}
17
+ @canvasHeight={{this.canvasHeightPx}}
18
+ @onSelect={{this.handleSelectElement}}
19
+ @onMove={{@onMoveElement}}
20
+ @onResize={{@onResizeElement}}
21
+ />
22
+ {{/each}}
23
+ </div>
@@ -0,0 +1,116 @@
1
+ import Component from '@glimmer/component';
2
+ import { action } from '@ember/object';
3
+ import { guidFor } from '@ember/object/internals';
4
+
5
+ /**
6
+ * TemplateBuilderCanvasComponent
7
+ *
8
+ * Renders the document canvas — a pixel-accurate representation of the
9
+ * template page. Each element is rendered by an ElementRenderer component
10
+ * that owns its own interact.js instance, drag/resize behaviour, and
11
+ * selection tap handling.
12
+ *
13
+ * This component is intentionally thin:
14
+ * - Computes canvas dimensions and style from @template
15
+ * - Tracks which element is selected (by uuid primitive)
16
+ * - Handles deselect-all when the canvas background is clicked
17
+ * - Passes @canvasWidth / @canvasHeight to each ElementRenderer so it can
18
+ * clamp drag/resize to the canvas boundary
19
+ *
20
+ * It has no knowledge of interact.js, DOM nodes, or element data mutations.
21
+ *
22
+ * @argument {Object} template - The template object (width, height, unit, background_color, content)
23
+ * @argument {Object} selectedElement - The currently selected element (or null)
24
+ * @argument {Number} zoom - Zoom level (1 = 100%)
25
+ * @argument {Function} onSelectElement - Called with (element) when an element is tapped
26
+ * @argument {Function} onMoveElement - Called with (uuid, { x, y }) after a drag ends
27
+ * @argument {Function} onResizeElement - Called with (uuid, { x, y, width, height }) after a resize ends
28
+ * @argument {Function} onDeselectAll - Called when the canvas background is clicked
29
+ */
30
+ export default class TemplateBuilderCanvasComponent extends Component {
31
+ canvasId = `tb-canvas-${guidFor(this)}`;
32
+
33
+ // No local selection state. The canvas derives selectedUuid from
34
+ // @selectedElement (owned by TemplateBuilderComponent) so that selections
35
+ // made from the layers panel, keyboard shortcuts, or any other source are
36
+ // always reflected here without duplication.
37
+
38
+ // -------------------------------------------------------------------------
39
+ // Canvas dimensions
40
+ // -------------------------------------------------------------------------
41
+
42
+ get zoom() {
43
+ return this.args.zoom ?? 1;
44
+ }
45
+
46
+ get canvasWidthPx() {
47
+ const { template } = this.args;
48
+ if (!template) return 794;
49
+ return this._unitToPx(template.width ?? 210, template.unit ?? 'mm');
50
+ }
51
+
52
+ get canvasHeightPx() {
53
+ const { template } = this.args;
54
+ if (!template) return 1123;
55
+ return this._unitToPx(template.height ?? 297, template.unit ?? 'mm');
56
+ }
57
+
58
+ get canvasStyle() {
59
+ const w = this.canvasWidthPx * this.zoom;
60
+ const h = this.canvasHeightPx * this.zoom;
61
+ const bg = this.args.template?.background_color ?? '#ffffff';
62
+ return `width:${w}px; height:${h}px; background:${bg}; position:relative; overflow:hidden;`;
63
+ }
64
+
65
+ get elements() {
66
+ return this.args.template?.content ?? [];
67
+ }
68
+
69
+ get selectedUuid() {
70
+ return this.args.selectedElement?.uuid ?? null;
71
+ }
72
+
73
+ // -------------------------------------------------------------------------
74
+ // Selection
75
+ // -------------------------------------------------------------------------
76
+
77
+ @action
78
+ handleSelectElement(element) {
79
+ // Delegate entirely to the parent. The parent sets selectedElement,
80
+ // which flows back down as @selectedElement, which drives selectedUuid.
81
+ if (this.args.onSelectElement) {
82
+ this.args.onSelectElement(element);
83
+ }
84
+ }
85
+
86
+ @action
87
+ handleDeselectAll(event) {
88
+ // Only deselect when the user clicks the canvas background directly.
89
+ // If the click originated from a child element (an ElementRenderer),
90
+ // interact.js has already fired its tap event and called handleSelectElement.
91
+ // We must not clear the selection here in that case.
92
+ if (event.target !== event.currentTarget) return;
93
+ if (this.args.onDeselectAll) {
94
+ this.args.onDeselectAll();
95
+ }
96
+ }
97
+
98
+ // -------------------------------------------------------------------------
99
+ // Helpers
100
+ // -------------------------------------------------------------------------
101
+
102
+ _unitToPx(value, unit) {
103
+ const PPI = 96;
104
+ switch (unit) {
105
+ case 'mm':
106
+ return Math.round((value / 25.4) * PPI);
107
+ case 'cm':
108
+ return Math.round((value / 2.54) * PPI);
109
+ case 'in':
110
+ return Math.round(value * PPI);
111
+ case 'px':
112
+ default:
113
+ return Math.round(value);
114
+ }
115
+ }
116
+ }
@@ -0,0 +1,126 @@
1
+ {{! Template Builder Element Renderer }}
2
+ {{! Each element owns its own interact.js instance. did-insert sets it up;
3
+ will-destroy tears it down. The parent canvas passes @onMove, @onResize,
4
+ and @onSelect callbacks — it never touches the DOM directly. }}
5
+ <div
6
+ class="tb-element {{this.selectionClass}}"
7
+ style={{this.wrapperStyle}}
8
+ data-element-type={{this.elementType}}
9
+ data-element-uuid={{@element.uuid}}
10
+ {{did-insert this.handleInsert}}
11
+ {{did-update this.handleUpdate @element}}
12
+ {{will-destroy this.handleDestroy}}
13
+ >
14
+ {{! TEXT }}
15
+ {{#if this.isText}}
16
+ <div class="tb-element-text" style={{this.textStyle}}>
17
+ {{this.textContent}}
18
+ </div>
19
+ {{/if}}
20
+
21
+ {{! IMAGE }}
22
+ {{#if this.isImage}}
23
+ {{#if this.imageSrc}}
24
+ <img
25
+ src={{this.imageSrc}}
26
+ alt={{this.imageAlt}}
27
+ style={{this.imageStyle}}
28
+ draggable="false"
29
+ />
30
+ {{else}}
31
+ <div class="w-full h-full flex items-center justify-center bg-gray-100 dark:bg-gray-700 border border-dashed border-gray-300 dark:border-gray-600 rounded">
32
+ <div class="text-center text-gray-400 dark:text-gray-500 text-xs">
33
+ <FaIcon @icon="image" @size="lg" class="mb-1 block" />
34
+ <span>Image</span>
35
+ </div>
36
+ </div>
37
+ {{/if}}
38
+ {{/if}}
39
+
40
+ {{! TABLE }}
41
+ {{#if this.isTable}}
42
+ <div class="tb-element-table w-full h-full overflow-auto">
43
+ <table class="w-full border-collapse text-xs" style={{this.tableBorderStyle}}>
44
+ {{#if this.tableColumns}}
45
+ <thead>
46
+ <tr>
47
+ {{#each this.tableColumns as |col|}}
48
+ <th class="border px-2 py-1 text-left" style={{this.tableHeaderStyle}}>
49
+ {{col.label}}
50
+ </th>
51
+ {{/each}}
52
+ </tr>
53
+ </thead>
54
+ <tbody>
55
+ {{#if this.tableRows.length}}
56
+ {{#each this.tableRows as |row|}}
57
+ <tr>
58
+ {{#each this.tableColumns as |col|}}
59
+ <td class="border px-2 py-1" style={{this.tableCellStyle}}>
60
+ {{get row col.key}}
61
+ </td>
62
+ {{/each}}
63
+ </tr>
64
+ {{/each}}
65
+ {{else}}
66
+ {{! Preview placeholder rows }}
67
+ {{#each (array 1 2 3)}}
68
+ <tr>
69
+ {{#each this.tableColumns}}
70
+ <td class="border px-2 py-1 text-gray-400" style={{this.tableCellStyle}}>—</td>
71
+ {{/each}}
72
+ </tr>
73
+ {{/each}}
74
+ {{/if}}
75
+ </tbody>
76
+ {{else}}
77
+ <tbody>
78
+ <tr>
79
+ <td class="border px-2 py-1 text-gray-400 text-center" colspan="1">No columns defined</td>
80
+ </tr>
81
+ </tbody>
82
+ {{/if}}
83
+ </table>
84
+ </div>
85
+ {{/if}}
86
+
87
+ {{! LINE }}
88
+ {{#if this.isLine}}
89
+ <div class="tb-element-line" style={{this.lineStyle}}>
90
+ <div style={{this.lineInnerStyle}}></div>
91
+ </div>
92
+ {{/if}}
93
+
94
+ {{! SHAPE }}
95
+ {{#if this.isShape}}
96
+ <div class="tb-element-shape" style={{this.shapeStyle}}></div>
97
+ {{/if}}
98
+
99
+ {{! QR CODE }}
100
+ {{#if this.isQrCode}}
101
+ <div class="w-full h-full flex items-center justify-center bg-gray-50 dark:bg-gray-800 border border-dashed border-gray-300 dark:border-gray-600 rounded">
102
+ <div class="text-center text-gray-400 dark:text-gray-500 text-xs">
103
+ <FaIcon @icon="qrcode" @size="2x" class="mb-1 block" />
104
+ <span class="block truncate max-w-full px-1">{{this.codeLabel}}</span>
105
+ </div>
106
+ </div>
107
+ {{/if}}
108
+
109
+ {{! BARCODE }}
110
+ {{#if this.isBarcode}}
111
+ <div class="w-full h-full flex items-center justify-center bg-gray-50 dark:bg-gray-800 border border-dashed border-gray-300 dark:border-gray-600 rounded">
112
+ <div class="text-center text-gray-400 dark:text-gray-500 text-xs">
113
+ <FaIcon @icon="barcode" @size="2x" class="mb-1 block" />
114
+ <span class="block truncate max-w-full px-1">{{this.codeLabel}}</span>
115
+ </div>
116
+ </div>
117
+ {{/if}}
118
+
119
+ {{! Selection handles (shown when selected) }}
120
+ {{#if @isSelected}}
121
+ <div class="tb-element-handle tb-handle-nw"></div>
122
+ <div class="tb-element-handle tb-handle-ne"></div>
123
+ <div class="tb-element-handle tb-handle-sw"></div>
124
+ <div class="tb-element-handle tb-handle-se"></div>
125
+ {{/if}}
126
+ </div>
@@ -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
+ }