nti_receipt_builder 0.1.0

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 (58) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +21 -0
  3. data/CODE_OF_CONDUCT.md +10 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +272 -0
  6. data/Rakefile +12 -0
  7. data/app/assets/javascripts/nti_receipt_builder/index.js +16 -0
  8. data/app/assets/javascripts/nti_receipt_builder/receipt_builder_controller.js +297 -0
  9. data/app/assets/javascripts/nti_receipt_builder/receipt_canvas_controller.js +279 -0
  10. data/app/assets/javascripts/nti_receipt_builder/receipt_draggable_controller.js +154 -0
  11. data/app/assets/javascripts/nti_receipt_builder/receipt_print_controller.js +24 -0
  12. data/app/assets/javascripts/nti_receipt_builder/receipt_properties_controller.js +297 -0
  13. data/app/assets/stylesheets/nti_receipt_builder.css +238 -0
  14. data/app/controllers/concerns/nti_receipt_builder/templates_controller.rb +170 -0
  15. data/app/helpers/nti_receipt_builder/render_helper.rb +21 -0
  16. data/app/models/nti_receipt_builder/template.rb +79 -0
  17. data/app/views/nti_receipt_builder/render/_order_lines_element.html.erb +36 -0
  18. data/app/views/nti_receipt_builder/render/_overflow_banner.html.erb +5 -0
  19. data/app/views/nti_receipt_builder/render/_paper.html.erb +12 -0
  20. data/app/views/nti_receipt_builder/render/_text_element.html.erb +1 -0
  21. data/app/views/nti_receipt_builder/render/_variable_element.html.erb +1 -0
  22. data/app/views/nti_receipt_builder/templates/_canvas.html.erb +33 -0
  23. data/app/views/nti_receipt_builder/templates/_elements_panel.html.erb +49 -0
  24. data/app/views/nti_receipt_builder/templates/_errors.html.erb +21 -0
  25. data/app/views/nti_receipt_builder/templates/_lock_version_field.html.erb +2 -0
  26. data/app/views/nti_receipt_builder/templates/_print_assets.html.erb +15 -0
  27. data/app/views/nti_receipt_builder/templates/_properties_panel.html.erb +8 -0
  28. data/app/views/nti_receipt_builder/templates/_settings_panel.html.erb +130 -0
  29. data/app/views/nti_receipt_builder/templates/create.turbo_stream.erb +3 -0
  30. data/app/views/nti_receipt_builder/templates/edit.html.erb +54 -0
  31. data/app/views/nti_receipt_builder/templates/new.html.erb +115 -0
  32. data/app/views/nti_receipt_builder/templates/preview.html.erb +24 -0
  33. data/app/views/nti_receipt_builder/templates/print.html.erb +31 -0
  34. data/app/views/nti_receipt_builder/templates/render_preview.turbo_stream.erb +26 -0
  35. data/app/views/nti_receipt_builder/templates/update.turbo_stream.erb +7 -0
  36. data/config/importmap.rb +4 -0
  37. data/lib/generators/nti_receipt_builder/install_generator.rb +49 -0
  38. data/lib/generators/nti_receipt_builder/templates/create_nti_receipt_templates.rb +28 -0
  39. data/lib/generators/nti_receipt_builder/templates/initializer.rb +16 -0
  40. data/lib/generators/nti_receipt_builder/templates/turbo_stream_tags.rb +36 -0
  41. data/lib/nti_receipt_builder/configuration.rb +59 -0
  42. data/lib/nti_receipt_builder/elements.rb +46 -0
  43. data/lib/nti_receipt_builder/engine.rb +32 -0
  44. data/lib/nti_receipt_builder/errors.rb +13 -0
  45. data/lib/nti_receipt_builder/layout_validator.rb +285 -0
  46. data/lib/nti_receipt_builder/presenters/collection.rb +126 -0
  47. data/lib/nti_receipt_builder/presenters/element.rb +68 -0
  48. data/lib/nti_receipt_builder/render_result.rb +18 -0
  49. data/lib/nti_receipt_builder/renderer.rb +78 -0
  50. data/lib/nti_receipt_builder/set_default.rb +24 -0
  51. data/lib/nti_receipt_builder/template_form.rb +60 -0
  52. data/lib/nti_receipt_builder/type_inference.rb +26 -0
  53. data/lib/nti_receipt_builder/variable_definition.rb +74 -0
  54. data/lib/nti_receipt_builder/variable_resolver.rb +55 -0
  55. data/lib/nti_receipt_builder/variables.rb +159 -0
  56. data/lib/nti_receipt_builder/version.rb +5 -0
  57. data/lib/nti_receipt_builder.rb +50 -0
  58. metadata +175 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 4493131823c3d523ede3d5e0c57b77caf81245f191dca1019ba5536cdf1dbc55
4
+ data.tar.gz: ce594bac64cd068754246e4cf62b90eed741e87c3dc5224dcdd570da267616db
5
+ SHA512:
6
+ metadata.gz: e8b7e1012da9720f4bba538fd3363a93fe4d349c75f6fd8de4c7d66c5dbc094c08b62603e643ebd19579494af386ed9931e9551dab09193cf2becf610006bbce
7
+ data.tar.gz: 916a9a7b7f8d7101e663227821564cd5d9391352189c95e465e16d5bd41069cbbcef1cfc915c17dcd481a70f7aac3e7ab37112619439b9b4c33adbc16ec5f61b
data/CHANGELOG.md ADDED
@@ -0,0 +1,21 @@
1
+ # Changelog
2
+
3
+ ## [0.1.0] - 2026-08-02
4
+
5
+ Initial release, extracted from SalesWiz's receipt builder.
6
+
7
+ - `NtiReceiptBuilder::Variables` — host-declared receipt vocabulary. `MAPPINGS` supplies keys
8
+ and value methods; label, formatting type and preview sample are derived and overridable per
9
+ key with `variable`. The key declaring `columns:` is the collection.
10
+ - `NtiReceiptBuilder::Template` — paper geometry, validations and jsonb layout, with
11
+ `inheritance_column = nil` so hosts can subclass and share the table without STI.
12
+ - `NtiReceiptBuilder::LayoutValidator` — structural and whitelist validation driven by the
13
+ host's declared variable and column keys.
14
+ - `NtiReceiptBuilder::Renderer` — one render path for both the designer preview and printed
15
+ output; omit `receipt_object:` to render declared samples.
16
+ - `NtiReceiptBuilder::TemplatesController` — the builder's actions as a concern, scoped through
17
+ an abstract `receipt_owner` the host must define.
18
+ - Builder UI: five Stimulus controllers, builder and print views, and a `nrb-receipt-*`
19
+ stylesheet themable via `--nrb-accent`.
20
+ - `rails generate nti_receipt_builder:install` — initializer, migration, and Turbo Stream
21
+ helpers for hosts that lack them.
@@ -0,0 +1,10 @@
1
+ # Code of Conduct
2
+
3
+ "nti_receipt_builder" follows [The Ruby Community Conduct Guideline](https://www.ruby-lang.org/en/conduct) in all "collaborative space", which is defined as community communications channels (such as mailing lists, submitted patches, commit comments, etc.):
4
+
5
+ * Participants will be tolerant of opposing views.
6
+ * Participants must ensure that their language and actions are free of personal attacks and disparaging personal remarks.
7
+ * When interpreting the words and actions of others, participants should always assume good intentions.
8
+ * Behaviour which can be reasonably considered harassment will not be tolerated.
9
+
10
+ If you have any concerns about behaviour within this project, please contact us at ["denmarkmeralpis@gmail.com"](mailto:"denmarkmeralpis@gmail.com").
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Den Meralpis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,272 @@
1
+ # NtiReceiptBuilder
2
+
3
+ A drag-and-drop receipt template designer and millimetre-accurate renderer, packaged as a
4
+ Rails engine.
5
+
6
+ The gem ships the whole vertical — model, layout validation, renderer, print views, and a
7
+ Stimulus builder UI — but carries **no domain vocabulary of its own**. Your application
8
+ declares a `Variables` subclass mapping receipt placeholders to methods on whatever object it
9
+ prints receipts for, and the gem drives the designer, the validator and the renderer from it.
10
+
11
+ ## Installation
12
+
13
+ ```ruby
14
+ # Gemfile
15
+ gem 'nti_receipt_builder'
16
+ ```
17
+
18
+ ```bash
19
+ bundle install
20
+ bin/rails generate nti_receipt_builder:install
21
+ bin/rails db:migrate
22
+ ```
23
+
24
+ The generator creates:
25
+
26
+ - `config/initializers/nti_receipt_builder.rb`
27
+ - a migration creating `nti_receipt_templates`
28
+ - `config/initializers/turbo_stream_tags.rb` — **skipped** if your app already defines
29
+ `open_modal` on `Turbo::Streams::TagBuilder`
30
+
31
+ ## Declaring your variables
32
+
33
+ `MAPPINGS` supplies the placeholder keys and the method that produces each value. Everything
34
+ else is derived, and overridden per key with `variable` only where the derived default is
35
+ wrong.
36
+
37
+ ```ruby
38
+ class OrderReceiptVariables < NtiReceiptBuilder::Variables
39
+ MAPPINGS = {
40
+ 'CUSTOMER_NAME' => 'customer_name',
41
+ 'ORDER_REFERENCE' => 'order_reference',
42
+ 'ORDER_SUBTOTAL' => 'order_subtotal',
43
+ 'ORDER_DATETIME' => 'order_datetime',
44
+ 'ORDER_LINES' => 'order_lines'
45
+ }.freeze
46
+
47
+ variable 'CUSTOMER_NAME', sample: 'Juan Dela Cruz'
48
+ variable 'ORDER_SUBTOTAL', sample: BigDecimal('275.00')
49
+ variable 'ORDER_DATETIME', label: 'Order Date/Time',
50
+ sample: -> { Time.zone.local(2026, 7, 31, 14, 32) }
51
+ variable 'ORDER_LINES', columns: {
52
+ 'description' => { label: 'Description', width_mm: 24 },
53
+ 'quantity' => { label: 'Qty', width_mm: 8, align: 'center' },
54
+ 'unit_price' => { label: 'Unit Price', width_mm: 12, align: 'right' },
55
+ 'line_total' => { label: 'Total', width_mm: 12, align: 'right' }
56
+ }
57
+
58
+ def customer_name = receipt_object.customer.name
59
+ def order_reference = receipt_object.reference
60
+ def order_subtotal = receipt_object.subtotal
61
+ def order_datetime = receipt_object.created_at
62
+
63
+ def order_lines
64
+ receipt_object.order_lines.order(:order_line_number).map do |line|
65
+ { 'description' => line.product_name, 'quantity' => line.quantity,
66
+ 'unit_price' => line.retail_price, 'line_total' => line.subtotal }
67
+ end
68
+ end
69
+ end
70
+ ```
71
+
72
+ `receipt_object` is whatever you pass to the renderer — an order, an invoice, a booking.
73
+
74
+ ### What is derived, and what you can override
75
+
76
+ | Aspect | Derived default | Override |
77
+ |---|---|---|
78
+ | Label | `key.titleize` — `'STORE_NAME'` → `'Store Name'` | `label:` |
79
+ | Formatting type | the value's class at render time: `BigDecimal` → currency, responds to `strftime` → datetime, `Array` → collection, other `Numeric` → plain, else string | `type:` |
80
+ | Preview sample | the label as a string; a collection gets three filler rows | `sample:` — a value or a lambda |
81
+ | Collection | the one key declaring `columns:` **is** the collection | — |
82
+
83
+ Type is decided at format time, not declaration time, so real data and preview samples travel
84
+ the same path. `type:` exists for the cases inference cannot get right — a pre-formatted
85
+ `String` that should be currency, say.
86
+
87
+ Column options are `label:`, `type:`, `width_mm:` and `align:`, all optional. They whitelist
88
+ the column keys a saved layout may use, seed the builder when a designer drops a fresh table,
89
+ and decide cell formatting.
90
+
91
+ A malformed class fails when its schema is first read — missing `MAPPINGS`, a mapped method
92
+ that does not exist, a `variable` call for a key absent from `MAPPINGS`, or two keys declaring
93
+ `columns:` all raise `NtiReceiptBuilder::InvalidVariablesError`. Typos surface in development
94
+ rather than at print time.
95
+
96
+ An error raised inside a mapping method propagates. That is deliberate: a receipt printing
97
+ silently without its total is worse than a visible failure.
98
+
99
+ ## Configuration
100
+
101
+ ```ruby
102
+ NtiReceiptBuilder.configure do |config|
103
+ config.variables_class = 'OrderReceiptVariables' # required
104
+ config.template_class = 'ReceiptTemplate' # default 'NtiReceiptBuilder::Template'
105
+ config.currency_unit = '₱'
106
+ config.datetime_format = '%m/%d/%Y %I:%M %p'
107
+ config.print_stylesheet = 'nti_receipt_builder' # nil to load none
108
+ end
109
+ ```
110
+
111
+ Class references may be given as a `Class` or a `String`; either way the gem retains only the
112
+ **name** and resolves it per access. Holding a reloadable application class in configuration
113
+ would pin that class and its constant tree past every development reload, and would serve a
114
+ stale class after your code reloads.
115
+
116
+ ## Wiring a controller
117
+
118
+ The gem ships its actions as a concern. Your controller keeps routing, authorization and
119
+ tenant scoping.
120
+
121
+ ```ruby
122
+ class Partner::ReceiptTemplatesController < PartnerController
123
+ include NtiReceiptBuilder::TemplatesController
124
+
125
+ def index
126
+ # yours — datatable, decorators, whatever you already use
127
+ end
128
+
129
+ private
130
+
131
+ def receipt_owner = current_account
132
+ end
133
+ ```
134
+
135
+ ```ruby
136
+ # config/routes.rb
137
+ resources :receipt_templates do
138
+ member do
139
+ get :preview
140
+ post :render_preview
141
+ get :print
142
+ patch :set_default
143
+ end
144
+ end
145
+ ```
146
+
147
+ `receipt_owner` is mandatory: every query the concern makes is scoped through it, so the
148
+ tenant boundary stays visible in your code rather than in a configured lambda. A controller
149
+ that omits it raises `NotImplementedError` instead of running an unscoped query.
150
+
151
+ The concern provides `new`, `create`, `edit`, `update`, `destroy`, `preview`,
152
+ `render_preview`, `print` and `set_default`. Define any of them in your controller to take
153
+ over — a method on the including class wins. SalesWiz overrides `destroy`, for instance, to
154
+ archive rather than delete. The navigation hooks `after_create_path`, `after_destroy_path` and
155
+ `after_set_default_path` are overridable too, and worth overriding if you have named routes.
156
+
157
+ ### Subclassing the model
158
+
159
+ Point `template_class` at a subclass to attach your own concerns while sharing the table:
160
+
161
+ ```ruby
162
+ class ReceiptTemplate < NtiReceiptBuilder::Template
163
+ # See the note below — these two lines go together.
164
+ def self.base_class = self
165
+ self.table_name = 'nti_receipt_templates'
166
+
167
+ include Auditable
168
+ has_assignable_status
169
+
170
+ validates :is_default, uniqueness: { scope: %i[owner_type owner_id],
171
+ conditions: -> { where(is_default: true) } }
172
+ end
173
+ ```
174
+
175
+ The parent sets `inheritance_column = nil`, so the subclass shares `nti_receipt_templates`
176
+ with no STI type condition and no `type` column to maintain. Host-specific columns go in your
177
+ own migration against the same table.
178
+
179
+ **If you subclass, override `base_class` and re-declare the table.** Rails resolves
180
+ `base_class` up the superclass chain, so without the override it returns
181
+ `NtiReceiptBuilder::Template`. Anything that records a class name from `base_class` —
182
+ PaperTrail's `item_type`, polymorphic `*_type` columns — would then store the gem's class name
183
+ rather than yours, and your own queries would not find those rows. Returning `self` is safe
184
+ precisely because there is no STI to disturb.
185
+
186
+ The two lines are a pair: once the subclass is its own `base_class`, Rails derives the table
187
+ name from *it*, which would give `receipt_templates`. Naming the engine's table explicitly
188
+ keeps them on the same rows.
189
+
190
+ ### Overriding a view
191
+
192
+ Host view prefixes are searched before the gem's, so dropping
193
+ `app/views/partner/receipt_templates/edit.html.erb` into your app shadows the gem's copy with
194
+ no configuration. The same trick works for `_print_assets.html.erb` if you want exact control
195
+ over what the standalone print document loads.
196
+
197
+ ## Rendering a receipt
198
+
199
+ ```ruby
200
+ @render_result = NtiReceiptBuilder::Renderer.new(
201
+ template: template, receipt_object: order
202
+ ).call
203
+
204
+ render template: 'nti_receipt_builder/templates/print', layout: false
205
+ ```
206
+
207
+ Omit `receipt_object:` and the renderer uses your declared samples without calling a single
208
+ mapping method — that is what the designer's preview does.
209
+
210
+ `RenderResult` exposes `elements` (presenters in paint order), `overflow?` and
211
+ `content_height_mm`. Preload whatever associations your scalar mappings traverse; the gem
212
+ cannot know them.
213
+
214
+ ## JavaScript and CSS
215
+
216
+ ```js
217
+ // app/javascript/controllers/index.js
218
+ import { registerNtiReceiptBuilder } from 'nti_receipt_builder'
219
+
220
+ registerNtiReceiptBuilder(application)
221
+ ```
222
+
223
+ ```css
224
+ /* app/assets/stylesheets/application.css */
225
+ @import 'nti_receipt_builder';
226
+ ```
227
+
228
+ Identifiers are `nrb-builder`, `nrb-canvas`, `nrb-draggable`, `nrb-print` and `nrb-properties`.
229
+ Set `--nrb-accent` and `--nrb-accent-rgb` to tint the builder's selection and palette
230
+ affordances; both fall back to Tabler's primary.
231
+
232
+ ## Host requirements
233
+
234
+ 1. **Tabler 1.x with Bootstrap JS** in the layout — the builder chrome uses Tabler's card,
235
+ button and form classes, and the preview modal uses Bootstrap's dismiss behaviour.
236
+ 2. **`Turbo::Streams::TagBuilder` responding to** `open_modal(size:, &block)`, `close_modal`,
237
+ `toast(message, type:)` and `reload_datatable(id)`, with matching `turbo_stream` action
238
+ handlers in your JavaScript. The install generator supplies these if you lack them.
239
+ 3. **`receipt_owner`** on the controller including the concern.
240
+ 4. Optional: a global `window.Toast` with `.warning(message)` for the element-limit notice.
241
+ The builder degrades silently without it.
242
+ 5. Optional: the `material-symbols-outlined` icon font, used for palette and toolbar icons.
243
+
244
+ ## Limits
245
+
246
+ Enforced by `LayoutValidator` before anything persists, which is what bounds the memory a
247
+ single template can consume:
248
+
249
+ | Limit | Value |
250
+ |---|---|
251
+ | Elements per template | 60 |
252
+ | Minimum element dimension | 1.0mm |
253
+ | Text length | 500 |
254
+ | Fallback / prefix / suffix | 200 / 50 / 50 |
255
+ | Order-line columns | 1–6 |
256
+ | Font size | 6–72pt |
257
+ | `z-index` | 0–1000 |
258
+
259
+ ## Development
260
+
261
+ ```bash
262
+ bin/setup
263
+ createdb nti_receipt_builder_test # specs need PostgreSQL — layout is jsonb
264
+ bundle exec rspec
265
+ bundle exec rubocop
266
+ ```
267
+
268
+ Specs run against a minimal dummy Rails app in `spec/dummy`.
269
+
270
+ ## License
271
+
272
+ MIT.
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require 'rubocop/rake_task'
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -0,0 +1,16 @@
1
+ import ReceiptBuilderController from "nti_receipt_builder/receipt_builder_controller"
2
+ import ReceiptCanvasController from "nti_receipt_builder/receipt_canvas_controller"
3
+ import ReceiptDraggableController from "nti_receipt_builder/receipt_draggable_controller"
4
+ import ReceiptPrintController from "nti_receipt_builder/receipt_print_controller"
5
+ import ReceiptPropertiesController from "nti_receipt_builder/receipt_properties_controller"
6
+
7
+ // Identifiers are registered explicitly rather than derived from file paths, so they stay short
8
+ // and stable regardless of where a host mounts the gem's JavaScript. They must match the
9
+ // data-controller attributes in the gem's views.
10
+ export function registerNtiReceiptBuilder(application) {
11
+ application.register("nrb-builder", ReceiptBuilderController)
12
+ application.register("nrb-canvas", ReceiptCanvasController)
13
+ application.register("nrb-draggable", ReceiptDraggableController)
14
+ application.register("nrb-print", ReceiptPrintController)
15
+ application.register("nrb-properties", ReceiptPropertiesController)
16
+ }
@@ -0,0 +1,297 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ const MAX_ELEMENTS = 60
4
+ const Z_INDEX_MAX = 1000
5
+
6
+ const DEFAULT_DIMENSIONS = {
7
+ variable: { widthMm: 40, heightMm: 5 },
8
+ text: { widthMm: 40, heightMm: 6 },
9
+ order_lines: { widthMm: 60, heightMm: 20 }
10
+ }
11
+
12
+ // Connects to data-controller="nrb-builder"
13
+ export default class extends Controller {
14
+ static targets = [
15
+ "layoutField",
16
+ "elementCount",
17
+ "paperSizeSelect",
18
+ "widthInput",
19
+ "heightInput",
20
+ "orientationSelect",
21
+ "heightModeSelect",
22
+ "marginTopInput",
23
+ "marginRightInput",
24
+ "marginBottomInput",
25
+ "marginLeftInput"
26
+ ]
27
+
28
+ static values = { layout: Array, presets: Object, collectionColumns: Array }
29
+ static outlets = ["nrb-canvas", "nrb-properties"]
30
+
31
+ initialize() {
32
+ this.selectedId = null
33
+ this.paletteDragHandler = null
34
+ }
35
+
36
+ // Stimulus calls this on connect and after every assignment to layoutValue — adding, patching,
37
+ // moving, resizing and deleting all route through one of those, so the hidden field is never
38
+ // stale.
39
+ //
40
+ // Serialising here rather than on submit is deliberate and load-bearing. Turbo builds the
41
+ // request body in the FormSubmission constructor, which runs BEFORE turbo:submit-start is
42
+ // dispatched from requestStarted(). A listener on that event writes the field after the body
43
+ // has already been snapshotted, so every edit was dropped and the server silently re-saved the
44
+ // layout the page was rendered with.
45
+ layoutValueChanged() {
46
+ this.serializeLayout()
47
+ }
48
+
49
+ disconnect() {
50
+ this.cancelPaletteDrag()
51
+ }
52
+
53
+ nrbCanvasOutletConnected() {
54
+ this.renderAll()
55
+ }
56
+
57
+ // Palette drag/drop
58
+
59
+ // The pointerup is attached to `document` because the gesture can end anywhere on the page.
60
+ // `{ once: true }` alone is not enough: if the pointerup never arrives — Turbo navigates away
61
+ // mid-gesture, or the pointer is released outside the document — the handler stays on
62
+ // `document` holding a closure over this controller, leaking one listener and one controller
63
+ // per visit. So keep a reference and detach it in disconnect().
64
+ cancelPaletteDrag() {
65
+ if (!this.paletteDragHandler) return
66
+
67
+ document.removeEventListener("pointerup", this.paletteDragHandler)
68
+ this.paletteDragHandler = null
69
+ }
70
+
71
+ startPaletteDrag(event) {
72
+ event.preventDefault()
73
+ const type = event.params.type
74
+ const key = event.params.key || null
75
+
76
+ this.cancelPaletteDrag()
77
+ this.paletteDragHandler = (upEvent) => {
78
+ this.paletteDragHandler = null
79
+ this.finishPaletteDrag(upEvent, type, key)
80
+ }
81
+ document.addEventListener("pointerup", this.paletteDragHandler, { once: true })
82
+ }
83
+
84
+ finishPaletteDrag(event, type, key) {
85
+ if (this.layoutValue.length >= MAX_ELEMENTS) {
86
+ this.showLimitReachedMessage()
87
+ return
88
+ }
89
+
90
+ const dimensions = DEFAULT_DIMENSIONS[type] || DEFAULT_DIMENSIONS.variable
91
+ let position = { xMm: 0, yMm: 0 }
92
+
93
+ if (this.hasNrbCanvasOutlet) {
94
+ const canvas = this.nrbCanvasOutlet
95
+ const target = document.elementFromPoint(event.clientX, event.clientY)
96
+ const droppedOnPaper = target && target.closest('[data-nrb-canvas-target="paper"]')
97
+
98
+ const raw = droppedOnPaper
99
+ ? canvas.pointToMm(event.clientX, event.clientY)
100
+ : canvas.centerMm(dimensions.widthMm, dimensions.heightMm)
101
+
102
+ position = canvas.clampPosition(raw.xMm, raw.yMm, dimensions.widthMm, dimensions.heightMm)
103
+ }
104
+
105
+ this.addElement(type, key, position, dimensions)
106
+ }
107
+
108
+ addElement(type, key, position, dimensions) {
109
+ const element = {
110
+ id: crypto.randomUUID(),
111
+ type,
112
+ x_mm: position.xMm,
113
+ y_mm: position.yMm,
114
+ width_mm: dimensions.widthMm,
115
+ height_mm: dimensions.heightMm,
116
+ z_index: this.nextZIndex(),
117
+ visible: true
118
+ }
119
+
120
+ if (type === "variable") {
121
+ element.styles = this.defaultStyles()
122
+ element.variable_key = key
123
+ element.fallback = ""
124
+ element.prefix = ""
125
+ element.suffix = ""
126
+ } else if (type === "text") {
127
+ element.styles = this.defaultStyles()
128
+ element.text = "New text"
129
+ } else {
130
+ element.config = this.defaultOrderLinesConfig()
131
+ }
132
+
133
+ this.layoutValue = [...this.layoutValue, element]
134
+ this.selectElement(element.id)
135
+ }
136
+
137
+ defaultStyles() {
138
+ return { font_size_pt: 10, font_weight: "normal", text_align: "left", line_height: 1.2 }
139
+ }
140
+
141
+ // Columns come from the host's variables class, not from here. Hardcoding keys would emit a
142
+ // layout the server-side validator rejects for any host whose collection differs.
143
+ defaultOrderLinesConfig() {
144
+ return {
145
+ show_header: true,
146
+ row_spacing_mm: 1.0,
147
+ font_size_pt: 8,
148
+ columns: this.collectionColumnsValue.map((column) => ({
149
+ key: column.key,
150
+ label: column.label,
151
+ width_mm: column.width_mm,
152
+ align: column.align
153
+ }))
154
+ }
155
+ }
156
+
157
+ nextZIndex() {
158
+ const max = this.layoutValue.reduce((acc, element) => Math.max(acc, element.z_index || 0), 0)
159
+ return Math.min(Z_INDEX_MAX, max + 1)
160
+ }
161
+
162
+ showLimitReachedMessage() {
163
+ if (window.Toast) window.Toast.warning(`You can add up to ${MAX_ELEMENTS} elements per template.`)
164
+ }
165
+
166
+ // Selection
167
+
168
+ elementSelected(event) {
169
+ this.selectElement(event.detail.id)
170
+ }
171
+
172
+ deselect() {
173
+ this.selectElement(null)
174
+ }
175
+
176
+ selectElement(id) {
177
+ if (id === this.selectedId) return
178
+
179
+ this.selectedId = id
180
+ this.renderAll()
181
+ this.syncProperties()
182
+ }
183
+
184
+ syncProperties() {
185
+ if (!this.hasNrbPropertiesOutlet) return
186
+
187
+ const element = this.selectedId ? this.layoutValue.find((candidate) => candidate.id === this.selectedId) : null
188
+ this.nrbPropertiesOutlet.render(element || null)
189
+ }
190
+
191
+ // Geometry / property changes
192
+
193
+ elementMoved(event) {
194
+ this.applyPatch(event.detail.id, { x_mm: event.detail.xMm, y_mm: event.detail.yMm })
195
+ }
196
+
197
+ elementResized(event) {
198
+ this.applyPatch(event.detail.id, {
199
+ x_mm: event.detail.xMm,
200
+ y_mm: event.detail.yMm,
201
+ width_mm: event.detail.widthMm,
202
+ height_mm: event.detail.heightMm
203
+ })
204
+ }
205
+
206
+ propertiesChanged(event) {
207
+ this.applyPatch(event.detail.id, event.detail.patch, { refreshProperties: false })
208
+ }
209
+
210
+ applyPatch(id, patch, { refreshProperties = true } = {}) {
211
+ this.layoutValue = this.layoutValue.map((element) => (element.id === id ? this.mergePatch(element, patch) : element))
212
+ this.renderAll()
213
+ if (refreshProperties && id === this.selectedId) this.syncProperties()
214
+ }
215
+
216
+ mergePatch(element, patch) {
217
+ const merged = { ...element, ...patch }
218
+ if (patch.styles) merged.styles = { ...(element.styles || {}), ...patch.styles }
219
+ if (patch.config) merged.config = { ...(element.config || {}), ...patch.config }
220
+ return merged
221
+ }
222
+
223
+ removeSelected(event) {
224
+ const id = (event && event.detail && event.detail.id) || this.selectedId
225
+ if (!id) return
226
+
227
+ this.layoutValue = this.layoutValue.filter((element) => element.id !== id)
228
+ if (this.selectedId === id) this.selectedId = null
229
+ this.renderAll()
230
+ this.syncProperties()
231
+ }
232
+
233
+ // Settings panel reactivity
234
+
235
+ settingsChanged() {
236
+ if (!this.hasNrbCanvasOutlet) return
237
+ const canvas = this.nrbCanvasOutlet
238
+
239
+ if (this.hasWidthInputTarget) {
240
+ const value = parseFloat(this.widthInputTarget.value)
241
+ if (!Number.isNaN(value)) canvas.widthMmValue = value
242
+ }
243
+ if (this.hasHeightInputTarget) {
244
+ const value = parseFloat(this.heightInputTarget.value)
245
+ if (!Number.isNaN(value)) canvas.heightMmValue = value
246
+ }
247
+ if (this.hasHeightModeSelectTarget) canvas.heightModeValue = this.heightModeSelectTarget.value
248
+ if (this.hasMarginTopInputTarget) canvas.marginTopMmValue = parseFloat(this.marginTopInputTarget.value) || 0
249
+ if (this.hasMarginRightInputTarget) canvas.marginRightMmValue = parseFloat(this.marginRightInputTarget.value) || 0
250
+ if (this.hasMarginBottomInputTarget) canvas.marginBottomMmValue = parseFloat(this.marginBottomInputTarget.value) || 0
251
+ if (this.hasMarginLeftInputTarget) canvas.marginLeftMmValue = parseFloat(this.marginLeftInputTarget.value) || 0
252
+ }
253
+
254
+ paperPresetChanged() {
255
+ const preset = this.presetsValue[this.paperSizeSelectTarget.value]
256
+ if (!preset) return
257
+
258
+ if (this.hasWidthInputTarget) this.widthInputTarget.value = preset.width_mm
259
+ if (this.hasHeightInputTarget) this.heightInputTarget.value = preset.height_mm
260
+ if (this.hasOrientationSelectTarget) this.orientationSelectTarget.value = preset.orientation
261
+
262
+ this.settingsChanged()
263
+ }
264
+
265
+ // Rendering / serialization
266
+
267
+ renderAll() {
268
+ if (this.hasNrbCanvasOutlet) {
269
+ this.nrbCanvasOutlet.renderElements(this.layoutValue, this.selectedId)
270
+ }
271
+ this.updateElementCount()
272
+ }
273
+
274
+ updateElementCount() {
275
+ if (!this.hasElementCountTarget) return
276
+ const count = this.layoutValue.length
277
+ this.elementCountTarget.textContent = `${count} element${count === 1 ? "" : "s"}`
278
+ }
279
+
280
+ // Guarded because layoutValueChanged fires during initialization, before targets connect. The
281
+ // field is server-rendered with the current layout, so a skipped first call loses nothing.
282
+ serializeLayout() {
283
+ if (!this.hasLayoutFieldTarget) return
284
+
285
+ this.layoutFieldTarget.value = JSON.stringify(this.layoutValue)
286
+ }
287
+
288
+ // Last line of defence at submit time, for a layout mutated in place without reassigning
289
+ // layoutValue. `new FormData(form)` dispatches formdata on the form, so this runs while Turbo
290
+ // is still assembling the body — early enough to change what is sent, which is exactly what
291
+ // turbo:submit-start was not.
292
+ writeLayoutToFormData(event) {
293
+ if (!this.hasLayoutFieldTarget) return
294
+
295
+ event.formData.set(this.layoutFieldTarget.name, JSON.stringify(this.layoutValue))
296
+ }
297
+ }