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
@@ -0,0 +1,279 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ const PX_PER_MM_AT_100 = 96 / 25.4
4
+ const MIN_ELEMENT_DIMENSION_MM = 1.0
5
+ // Keep in sync with NtiReceiptBuilder::Presenters::Collection.
6
+ const MM_PER_PT = 0.3528
7
+ const LINE_HEIGHT_FACTOR = 1.25
8
+
9
+ // Connects to data-controller="nrb-canvas"
10
+ export default class extends Controller {
11
+ static targets = ["viewport", "paper", "marginGuides", "elementsLayer", "zoomRange", "zoomLabel"]
12
+
13
+ static values = {
14
+ widthMm: Number,
15
+ heightMm: Number,
16
+ heightMode: String,
17
+ marginTopMm: Number,
18
+ marginRightMm: Number,
19
+ marginBottomMm: Number,
20
+ marginLeftMm: Number,
21
+ gridMm: Number,
22
+ zoom: { type: Number, default: 1 },
23
+ variableLabels: Object
24
+ }
25
+
26
+ connect() {
27
+ this.updatePaperSize()
28
+ this.updateMarginGuides()
29
+ this.applyZoom()
30
+ }
31
+
32
+ get pxPerMm() {
33
+ return PX_PER_MM_AT_100 * this.zoomValue
34
+ }
35
+
36
+ toPx(mm) {
37
+ return mm * this.pxPerMm
38
+ }
39
+
40
+ toMm(px) {
41
+ return px / this.pxPerMm
42
+ }
43
+
44
+ round(mm) {
45
+ return Math.round(mm * 100) / 100
46
+ }
47
+
48
+ snap(mm) {
49
+ const grid = this.gridMmValue || 1
50
+ return this.round(Math.round(mm / grid) * grid)
51
+ }
52
+
53
+ clampPosition(xMm, yMm, widthMm, heightMm) {
54
+ const maxX = Math.max(0, this.widthMmValue - widthMm)
55
+ const clampedX = Math.min(Math.max(0, xMm), maxX)
56
+
57
+ let clampedY = Math.max(0, yMm)
58
+ if (this.heightModeValue !== "content") {
59
+ const maxY = Math.max(0, this.heightMmValue - heightMm)
60
+ clampedY = Math.min(clampedY, maxY)
61
+ }
62
+
63
+ return { xMm: this.round(clampedX), yMm: this.round(clampedY) }
64
+ }
65
+
66
+ clampSize(widthMm, heightMm, xMm, yMm) {
67
+ const maxWidth = Math.max(MIN_ELEMENT_DIMENSION_MM, this.widthMmValue - xMm)
68
+ const clampedWidth = Math.min(Math.max(MIN_ELEMENT_DIMENSION_MM, widthMm), maxWidth)
69
+
70
+ let clampedHeight = Math.max(MIN_ELEMENT_DIMENSION_MM, heightMm)
71
+ if (this.heightModeValue !== "content") {
72
+ const maxHeight = Math.max(MIN_ELEMENT_DIMENSION_MM, this.heightMmValue - yMm)
73
+ clampedHeight = Math.min(clampedHeight, maxHeight)
74
+ }
75
+
76
+ return { widthMm: this.round(clampedWidth), heightMm: this.round(clampedHeight) }
77
+ }
78
+
79
+ pointToMm(clientX, clientY) {
80
+ const rect = this.paperTarget.getBoundingClientRect()
81
+ const xMm = this.toMm(clientX - rect.left)
82
+ const yMm = this.toMm(clientY - rect.top)
83
+
84
+ return {
85
+ xMm: this.round(Math.max(0, Math.min(xMm, this.widthMmValue))),
86
+ yMm: this.round(Math.max(0, Math.min(yMm, this.heightMmValue)))
87
+ }
88
+ }
89
+
90
+ centerMm(widthMm, heightMm) {
91
+ return {
92
+ xMm: this.round(Math.max(0, (this.widthMmValue - widthMm) / 2)),
93
+ yMm: this.round(Math.max(0, (this.heightMmValue - heightMm) / 2))
94
+ }
95
+ }
96
+
97
+ paperClicked() {
98
+ this.dispatch("deselect", { prefix: "receipt-canvas", bubbles: true })
99
+ }
100
+
101
+ zoomChanged(event) {
102
+ this.zoomValue = parseFloat(event.target.value)
103
+ }
104
+
105
+ zoomValueChanged(value) {
106
+ if (this.hasZoomLabelTarget) this.zoomLabelTarget.textContent = `${Math.round(value * 100)}%`
107
+ this.applyZoom()
108
+ }
109
+
110
+ widthMmValueChanged() {
111
+ this.updatePaperSize()
112
+ }
113
+
114
+ heightMmValueChanged() {
115
+ this.updatePaperSize()
116
+ }
117
+
118
+ heightModeValueChanged() {
119
+ this.updatePaperSize()
120
+ }
121
+
122
+ marginTopMmValueChanged() {
123
+ this.updateMarginGuides()
124
+ }
125
+
126
+ marginRightMmValueChanged() {
127
+ this.updateMarginGuides()
128
+ }
129
+
130
+ marginBottomMmValueChanged() {
131
+ this.updateMarginGuides()
132
+ }
133
+
134
+ marginLeftMmValueChanged() {
135
+ this.updateMarginGuides()
136
+ }
137
+
138
+ applyZoom() {
139
+ if (!this.hasPaperTarget) return
140
+ this.paperTarget.style.zoom = String(this.zoomValue)
141
+ }
142
+
143
+ updatePaperSize() {
144
+ if (!this.hasPaperTarget) return
145
+ this.paperTarget.style.width = `${this.widthMmValue}mm`
146
+ this.paperTarget.style.height = this.heightModeValue === "content" ? "auto" : `${this.heightMmValue}mm`
147
+ this.paperTarget.style.minHeight = this.heightModeValue === "content" ? `${this.heightMmValue}mm` : ""
148
+ }
149
+
150
+ updateMarginGuides() {
151
+ if (!this.hasMarginGuidesTarget) return
152
+ this.marginGuidesTarget.style.top = `${this.marginTopMmValue}mm`
153
+ this.marginGuidesTarget.style.right = `${this.marginRightMmValue}mm`
154
+ this.marginGuidesTarget.style.bottom = `${this.marginBottomMmValue}mm`
155
+ this.marginGuidesTarget.style.left = `${this.marginLeftMmValue}mm`
156
+ }
157
+
158
+ renderElements(layout, selectedId) {
159
+ if (!this.hasElementsLayerTarget) return
160
+
161
+ this.elementsLayerTarget.innerHTML = ""
162
+ const sorted = [...layout].sort((a, b) => (a.z_index || 0) - (b.z_index || 0))
163
+ sorted.forEach((element) => {
164
+ this.elementsLayerTarget.appendChild(this.buildElementNode(element, element.id === selectedId))
165
+ })
166
+ }
167
+
168
+ buildElementNode(element, selected) {
169
+ const node = document.createElement("div")
170
+ node.setAttribute("data-controller", "nrb-draggable")
171
+ node.setAttribute("data-nrb-draggable-element-id-value", element.id)
172
+ node.setAttribute("data-nrb-draggable-x-mm-value", element.x_mm)
173
+ node.setAttribute("data-nrb-draggable-y-mm-value", element.y_mm)
174
+ node.setAttribute("data-nrb-draggable-width-mm-value", element.width_mm)
175
+ node.setAttribute("data-nrb-draggable-height-mm-value", element.height_mm)
176
+ node.setAttribute("data-nrb-draggable-nrb-canvas-outlet", `#${this.element.id}`)
177
+ node.setAttribute("data-action", "pointerdown->nrb-draggable#dragStart")
178
+
179
+ node.classList.add("nrb-receipt-element", `nrb-receipt-element--${element.type}`)
180
+ if (selected) node.classList.add("nrb-receipt-element--selected")
181
+ if (element.visible === false) node.classList.add("nrb-receipt-element--hidden")
182
+
183
+ node.style.left = `${element.x_mm}mm`
184
+ node.style.top = `${element.y_mm}mm`
185
+ node.style.width = `${element.width_mm}mm`
186
+ node.style.height = `${element.height_mm}mm`
187
+ node.style.zIndex = String(element.z_index || 0)
188
+
189
+ const body = document.createElement("div")
190
+ body.className = "nrb-receipt-element-body"
191
+ this.renderElementContent(body, element)
192
+ node.appendChild(body)
193
+
194
+ const handle = document.createElement("div")
195
+ handle.className = "nrb-receipt-resize-handle"
196
+ handle.setAttribute("data-nrb-draggable-target", "resizeHandle")
197
+ handle.setAttribute("data-action", "pointerdown->nrb-draggable#resizeStart")
198
+ node.appendChild(handle)
199
+
200
+ return node
201
+ }
202
+
203
+ renderElementContent(body, element) {
204
+ if (element.type === "variable") {
205
+ this.applyTextStyles(body, element.styles || {})
206
+ const label = this.variableLabelsValue[element.variable_key] || element.variable_key || "Variable"
207
+ body.textContent = `${element.prefix || ""}{${label}}${element.suffix || ""}`
208
+ } else if (element.type === "text") {
209
+ this.applyTextStyles(body, element.styles || {})
210
+ body.textContent = element.text || ""
211
+ } else {
212
+ this.renderOrderLinesPlaceholder(body, element)
213
+ }
214
+ }
215
+
216
+ applyTextStyles(body, styles) {
217
+ body.style.fontSize = `${styles.font_size_pt || 10}pt`
218
+ body.style.fontWeight = styles.font_weight || "normal"
219
+ body.style.textAlign = styles.text_align || "left"
220
+ body.style.lineHeight = styles.line_height || 1.2
221
+ }
222
+
223
+ // Mirrors NtiReceiptBuilder::Presenters::Collection / _order_lines_element.html.erb —
224
+ // same table markup and same layout math, so the canvas placeholder matches
225
+ // what actually prints. Keep the constants below in sync with the presenter.
226
+ renderOrderLinesPlaceholder(body, element) {
227
+ const config = element.config || {}
228
+ const columns = config.columns || []
229
+ const fontSizePt = config.font_size_pt || 9
230
+ const rowSpacingMm = config.row_spacing_mm != null ? config.row_spacing_mm : 1.0
231
+ const totalWidthMm = columns.reduce((sum, column) => sum + (Number(column.width_mm) || 0), 0)
232
+
233
+ const table = document.createElement("table")
234
+ table.className = "nrb-receipt-order-lines-table"
235
+ table.style.fontSize = `${fontSizePt}pt`
236
+ table.style.lineHeight = `${fontSizePt * MM_PER_PT * LINE_HEIGHT_FACTOR}mm`
237
+
238
+ const colgroup = document.createElement("colgroup")
239
+ columns.forEach((column) => {
240
+ const col = document.createElement("col")
241
+ col.style.width = `${this.columnWidthPct(column, columns.length, totalWidthMm)}%`
242
+ colgroup.appendChild(col)
243
+ })
244
+ table.appendChild(colgroup)
245
+
246
+ if (config.show_header !== false) {
247
+ const thead = document.createElement("thead")
248
+ const header = document.createElement("tr")
249
+ header.className = "nrb-receipt-order-lines-header"
250
+ columns.forEach((column) => header.appendChild(this.buildCell(column, column.label, rowSpacingMm, "th")))
251
+ thead.appendChild(header)
252
+ table.appendChild(thead)
253
+ }
254
+
255
+ const tbody = document.createElement("tbody")
256
+ ;[1, 2].forEach(() => {
257
+ const row = document.createElement("tr")
258
+ columns.forEach((column) => row.appendChild(this.buildCell(column, "—", rowSpacingMm)))
259
+ tbody.appendChild(row)
260
+ })
261
+ table.appendChild(tbody)
262
+
263
+ body.appendChild(table)
264
+ }
265
+
266
+ columnWidthPct(column, columnCount, totalWidthMm) {
267
+ if (totalWidthMm > 0) return ((Number(column.width_mm) || 0) / totalWidthMm) * 100
268
+
269
+ return columnCount > 0 ? 100 / columnCount : 100
270
+ }
271
+
272
+ buildCell(column, text, rowSpacingMm, tag = "td") {
273
+ const cell = document.createElement(tag)
274
+ cell.style.textAlign = column.align || "left"
275
+ cell.style.paddingBottom = `${rowSpacingMm}mm`
276
+ cell.textContent = text
277
+ return cell
278
+ }
279
+ }
@@ -0,0 +1,154 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Connects to data-controller="nrb-draggable"
4
+ export default class extends Controller {
5
+ static values = {
6
+ elementId: String,
7
+ xMm: Number,
8
+ yMm: Number,
9
+ widthMm: Number,
10
+ heightMm: Number
11
+ }
12
+
13
+ static targets = ["resizeHandle"]
14
+ static outlets = ["nrb-canvas"]
15
+
16
+ // A gesture interrupted by navigation never reaches dragEnd/resizeEnd, so its cleanup path
17
+ // never runs and pointer capture is never released. Tear both down here.
18
+ disconnect() {
19
+ this.teardownDrag()
20
+ this.teardownResize()
21
+ }
22
+
23
+ teardownDrag() {
24
+ if (!this.boundDragMove) return
25
+
26
+ this.element.removeEventListener("pointermove", this.boundDragMove)
27
+ this.element.removeEventListener("pointerup", this.boundDragEnd)
28
+ this.element.removeEventListener("pointercancel", this.boundDragEnd)
29
+ this.element.classList.remove("nrb-receipt-element-dragging")
30
+ this.boundDragMove = null
31
+ this.boundDragEnd = null
32
+ this.dragOrigin = null
33
+ }
34
+
35
+ teardownResize() {
36
+ if (!this.boundResizeMove) return
37
+
38
+ if (this.hasResizeHandleTarget) {
39
+ this.resizeHandleTarget.removeEventListener("pointermove", this.boundResizeMove)
40
+ this.resizeHandleTarget.removeEventListener("pointerup", this.boundResizeEnd)
41
+ this.resizeHandleTarget.removeEventListener("pointercancel", this.boundResizeEnd)
42
+ }
43
+ this.element.classList.remove("nrb-receipt-element-resizing")
44
+ this.boundResizeMove = null
45
+ this.boundResizeEnd = null
46
+ this.resizeOrigin = null
47
+ }
48
+
49
+ dragStart(event) {
50
+ if (event.button !== 0) return
51
+ event.preventDefault()
52
+ event.stopPropagation()
53
+
54
+ this.dragOrigin = { clientX: event.clientX, clientY: event.clientY, xMm: this.xMmValue, yMm: this.yMmValue }
55
+ this.element.setPointerCapture(event.pointerId)
56
+ this.boundDragMove = this.dragMove.bind(this)
57
+ this.boundDragEnd = this.dragEnd.bind(this)
58
+ this.element.addEventListener("pointermove", this.boundDragMove)
59
+ this.element.addEventListener("pointerup", this.boundDragEnd)
60
+ this.element.addEventListener("pointercancel", this.boundDragEnd)
61
+ this.element.classList.add("nrb-receipt-element-dragging")
62
+ this.dispatchSelected()
63
+ }
64
+
65
+ dragMove(event) {
66
+ if (!this.dragOrigin || !this.hasNrbCanvasOutlet) return
67
+
68
+ const canvas = this.nrbCanvasOutlet
69
+ const deltaXMm = canvas.toMm(event.clientX - this.dragOrigin.clientX)
70
+ const deltaYMm = canvas.toMm(event.clientY - this.dragOrigin.clientY)
71
+ const snappedX = canvas.snap(this.dragOrigin.xMm + deltaXMm)
72
+ const snappedY = canvas.snap(this.dragOrigin.yMm + deltaYMm)
73
+ const { xMm, yMm } = canvas.clampPosition(snappedX, snappedY, this.widthMmValue, this.heightMmValue)
74
+
75
+ this.xMmValue = xMm
76
+ this.yMmValue = yMm
77
+ this.applyPosition()
78
+ }
79
+
80
+ dragEnd(event) {
81
+ if (!this.dragOrigin) return
82
+
83
+ this.element.releasePointerCapture(event.pointerId)
84
+ const detail = { id: this.elementIdValue, xMm: this.xMmValue, yMm: this.yMmValue }
85
+ this.teardownDrag()
86
+
87
+ this.dispatch("moved", { prefix: "receipt-element", bubbles: true, detail })
88
+ }
89
+
90
+ resizeStart(event) {
91
+ if (event.button !== 0) return
92
+ event.preventDefault()
93
+ event.stopPropagation()
94
+
95
+ this.resizeOrigin = {
96
+ clientX: event.clientX,
97
+ clientY: event.clientY,
98
+ widthMm: this.widthMmValue,
99
+ heightMm: this.heightMmValue,
100
+ xMm: this.xMmValue,
101
+ yMm: this.yMmValue
102
+ }
103
+ this.resizeHandleTarget.setPointerCapture(event.pointerId)
104
+ this.boundResizeMove = this.resizeMove.bind(this)
105
+ this.boundResizeEnd = this.resizeEnd.bind(this)
106
+ this.resizeHandleTarget.addEventListener("pointermove", this.boundResizeMove)
107
+ this.resizeHandleTarget.addEventListener("pointerup", this.boundResizeEnd)
108
+ this.resizeHandleTarget.addEventListener("pointercancel", this.boundResizeEnd)
109
+ this.element.classList.add("nrb-receipt-element-resizing")
110
+ this.dispatchSelected()
111
+ }
112
+
113
+ resizeMove(event) {
114
+ if (!this.resizeOrigin || !this.hasNrbCanvasOutlet) return
115
+
116
+ const canvas = this.nrbCanvasOutlet
117
+ const deltaWidthMm = canvas.toMm(event.clientX - this.resizeOrigin.clientX)
118
+ const deltaHeightMm = canvas.toMm(event.clientY - this.resizeOrigin.clientY)
119
+ const rawWidth = canvas.snap(this.resizeOrigin.widthMm + deltaWidthMm)
120
+ const rawHeight = canvas.snap(this.resizeOrigin.heightMm + deltaHeightMm)
121
+ const { widthMm, heightMm } = canvas.clampSize(rawWidth, rawHeight, this.resizeOrigin.xMm, this.resizeOrigin.yMm)
122
+
123
+ this.widthMmValue = widthMm
124
+ this.heightMmValue = heightMm
125
+ this.applyPosition()
126
+ }
127
+
128
+ resizeEnd(event) {
129
+ if (!this.resizeOrigin) return
130
+
131
+ this.resizeHandleTarget.releasePointerCapture(event.pointerId)
132
+ const detail = {
133
+ id: this.elementIdValue,
134
+ xMm: this.xMmValue,
135
+ yMm: this.yMmValue,
136
+ widthMm: this.widthMmValue,
137
+ heightMm: this.heightMmValue
138
+ }
139
+ this.teardownResize()
140
+
141
+ this.dispatch("resized", { prefix: "receipt-element", bubbles: true, detail })
142
+ }
143
+
144
+ dispatchSelected() {
145
+ this.dispatch("selected", { prefix: "receipt-element", bubbles: true, detail: { id: this.elementIdValue } })
146
+ }
147
+
148
+ applyPosition() {
149
+ this.element.style.left = `${this.xMmValue}mm`
150
+ this.element.style.top = `${this.yMmValue}mm`
151
+ this.element.style.width = `${this.widthMmValue}mm`
152
+ this.element.style.height = `${this.heightMmValue}mm`
153
+ }
154
+ }
@@ -0,0 +1,24 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Connects to data-controller="nrb-print"
4
+ export default class extends Controller {
5
+ static values = { auto: { type: Boolean, default: true } }
6
+
7
+ connect() {
8
+ if (!this.autoValue) return
9
+
10
+ // Cancelled on disconnect: an un-cancelled frame fires window.print() after the element is
11
+ // gone, which surfaces a print dialog over whatever the user navigated to.
12
+ this.printFrame = requestAnimationFrame(() => {
13
+ this.printFrame = null
14
+ window.print()
15
+ })
16
+ }
17
+
18
+ disconnect() {
19
+ if (!this.printFrame) return
20
+
21
+ cancelAnimationFrame(this.printFrame)
22
+ this.printFrame = null
23
+ }
24
+ }