@fleetbase/ember-ui 0.3.23 → 0.3.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/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,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
|
+
}
|