highlite 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 (45) hide show
  1. checksums.yaml +7 -0
  2. data/.claude/commands/publish.md +45 -0
  3. data/README.md +176 -0
  4. data/Rakefile +12 -0
  5. data/app/assets/stylesheets/highlite/viewer.css +607 -0
  6. data/app/helpers/highlite/application_helper.rb +20 -0
  7. data/app/javascript/highlite/controllers/.keep +0 -0
  8. data/app/javascript/highlite/controllers/highlight_controller.js +829 -0
  9. data/app/javascript/highlite/controllers/highlights_panel_controller.js +313 -0
  10. data/app/javascript/highlite/controllers/sidebar_controller.js +465 -0
  11. data/app/javascript/highlite/controllers/viewer_controller.js +373 -0
  12. data/app/javascript/highlite/index.js +30 -0
  13. data/app/javascript/highlite/lib/.keep +0 -0
  14. data/app/javascript/highlite/lib/highlight_store.js +235 -0
  15. data/app/javascript/highlite/lib/pdf_renderer.js +212 -0
  16. data/app/views/highlite/_floating_toolbar.html.erb +79 -0
  17. data/app/views/highlite/_left_sidebar.html.erb +43 -0
  18. data/app/views/highlite/_right_sidebar.html.erb +56 -0
  19. data/app/views/highlite/_toolbar.html.erb +63 -0
  20. data/app/views/highlite/_viewer.html.erb +63 -0
  21. data/config/importmap.rb +17 -0
  22. data/lib/generators/highlite/install/install_generator.rb +44 -0
  23. data/lib/generators/highlite/install/templates/_right_sidebar.html.erb.tt +16 -0
  24. data/lib/generators/highlite/install/templates/initializer.rb.tt +20 -0
  25. data/lib/highlite/configuration.rb +27 -0
  26. data/lib/highlite/engine.rb +26 -0
  27. data/lib/highlite/version.rb +5 -0
  28. data/lib/highlite.rb +17 -0
  29. data/sig/highlite.rbs +4 -0
  30. data/tasks/todo.md +129 -0
  31. data/test/dummy/Rakefile +3 -0
  32. data/test/dummy/app/controllers/application_controller.rb +2 -0
  33. data/test/dummy/app/controllers/documents_controller.rb +6 -0
  34. data/test/dummy/app/javascript/application.js +1 -0
  35. data/test/dummy/app/views/documents/show.html.erb +1 -0
  36. data/test/dummy/app/views/layouts/application.html.erb +13 -0
  37. data/test/dummy/bin/rails +4 -0
  38. data/test/dummy/config/application.rb +15 -0
  39. data/test/dummy/config/boot.rb +2 -0
  40. data/test/dummy/config/environment.rb +2 -0
  41. data/test/dummy/config/importmap.rb +3 -0
  42. data/test/dummy/config/routes.rb +3 -0
  43. data/test/dummy/config.ru +2 -0
  44. data/test/dummy/public/convention-over-configuration.pdf +0 -0
  45. metadata +117 -0
@@ -0,0 +1,373 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { PdfRenderer } from "highlite/lib/pdf_renderer"
3
+
4
+ /**
5
+ * ViewerController — Main Stimulus controller for the PDF viewer center panel.
6
+ *
7
+ * Renders all PDF pages in a scrollable container, tracks the current visible
8
+ * page via IntersectionObserver, and provides zoom controls and keyboard navigation.
9
+ *
10
+ * Targets:
11
+ * pagesContainer - Scrollable div that holds all page wrappers
12
+ * pageTemplate - A <template> element cloned for each page
13
+ * zoomLevel - Element displaying the current zoom percentage
14
+ * pageInfo - Element displaying "Page X of Y"
15
+ * loader - Loading overlay shown while PDF is loading
16
+ *
17
+ * Values:
18
+ * url (String) - URL of the PDF to load
19
+ * documentId (String) - Unique document ID for highlight storage
20
+ * scale (Number) - Current zoom scale (default 1.5)
21
+ *
22
+ * Actions:
23
+ * zoomIn, zoomOut, zoomFit, zoomAuto, scrollToPage
24
+ *
25
+ * Events dispatched on this.element:
26
+ * highlite:document-loaded { pageCount, title, outline }
27
+ * highlite:page-changed { page, totalPages }
28
+ */
29
+ export default class extends Controller {
30
+ static targets = [
31
+ "pagesContainer",
32
+ "pageTemplate",
33
+ "zoomLevel",
34
+ "pageInfo",
35
+ "loader",
36
+ ]
37
+
38
+ static values = {
39
+ url: String,
40
+ documentId: String,
41
+ scale: { type: Number, default: 2.25 },
42
+ }
43
+
44
+ connect() {
45
+ this.renderer = null
46
+ this.pageCount = 0
47
+ this.currentPage = 1
48
+ this._observer = null
49
+ this._pageElements = []
50
+ this._rendering = false
51
+
52
+ this._handleKeydown = this._onKeydown.bind(this)
53
+ document.addEventListener("keydown", this._handleKeydown)
54
+
55
+ if (this.urlValue) {
56
+ this._loadDocument()
57
+ }
58
+ }
59
+
60
+ disconnect() {
61
+ document.removeEventListener("keydown", this._handleKeydown)
62
+
63
+ if (this._observer) {
64
+ this._observer.disconnect()
65
+ this._observer = null
66
+ }
67
+
68
+ if (this.renderer) {
69
+ this.renderer.destroy()
70
+ this.renderer = null
71
+ }
72
+ }
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Value change callbacks
76
+ // ---------------------------------------------------------------------------
77
+
78
+ scaleValueChanged() {
79
+ this._updateZoomDisplay()
80
+ if (this.renderer && this.pageCount > 0) {
81
+ this._rerenderAllPages()
82
+ }
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Document loading
87
+ // ---------------------------------------------------------------------------
88
+
89
+ async _loadDocument() {
90
+ this._showLoader()
91
+
92
+ try {
93
+ this.renderer = new PdfRenderer(this.urlValue)
94
+ const { pageCount, title } = await this.renderer.load()
95
+ this.pageCount = pageCount
96
+
97
+ const outline = await this.renderer.getOutline()
98
+
99
+ this._createPageContainers()
100
+ await this._renderAllPages()
101
+ this._setupIntersectionObserver()
102
+ this._updatePageInfo(1)
103
+ this._updateZoomDisplay()
104
+
105
+ this.dispatch("document-loaded", {
106
+ detail: { pageCount, title, outline },
107
+ prefix: "highlite",
108
+ })
109
+ } catch (error) {
110
+ console.error("[highlite] Failed to load PDF:", error)
111
+ this._showError(error.message)
112
+ } finally {
113
+ this._hideLoader()
114
+ }
115
+ }
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // Page container setup
119
+ // ---------------------------------------------------------------------------
120
+
121
+ /**
122
+ * Create a wrapper div for each page in the PDF.
123
+ * Each wrapper contains: canvas + textLayer div + highlightLayer div.
124
+ */
125
+ _createPageContainers() {
126
+ const container = this.pagesContainerTarget
127
+ container.innerHTML = ""
128
+ this._pageElements = []
129
+
130
+ for (let i = 1; i <= this.pageCount; i++) {
131
+ const wrapper = document.createElement("div")
132
+ wrapper.className = "highlite-page"
133
+ wrapper.dataset.pageNumber = i
134
+
135
+ const canvas = document.createElement("canvas")
136
+ canvas.className = "highlite-page-canvas"
137
+
138
+ const textLayer = document.createElement("div")
139
+ textLayer.className = "highlite-text-layer"
140
+
141
+ const highlightLayer = document.createElement("div")
142
+ highlightLayer.className = "highlite-highlight-layer"
143
+
144
+ wrapper.appendChild(canvas)
145
+ wrapper.appendChild(textLayer)
146
+ wrapper.appendChild(highlightLayer)
147
+ container.appendChild(wrapper)
148
+
149
+ this._pageElements.push({ wrapper, canvas, textLayer, highlightLayer })
150
+ }
151
+ }
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // Rendering
155
+ // ---------------------------------------------------------------------------
156
+
157
+ async _renderAllPages() {
158
+ if (this._rendering) return
159
+ this._rendering = true
160
+
161
+ try {
162
+ for (let i = 0; i < this._pageElements.length; i++) {
163
+ const pageNum = i + 1
164
+ const { canvas, textLayer } = this._pageElements[i]
165
+
166
+ await this.renderer.renderPage(pageNum, canvas, this.scaleValue)
167
+
168
+ const viewport = await this.renderer.getViewport(
169
+ pageNum,
170
+ this.scaleValue
171
+ )
172
+
173
+ // Set --total-scale-factor for PDF.js text layer positioning.
174
+ // PDF.js TextLayer uses this for container width/height and font sizing.
175
+ // This should match viewport.scale (not multiplied by DPR — DPR is only
176
+ // for the canvas bitmap resolution, not for CSS layout).
177
+ textLayer.style.setProperty("--total-scale-factor", this.scaleValue)
178
+
179
+ await this.renderer.renderTextLayer(pageNum, textLayer, viewport)
180
+ }
181
+ } finally {
182
+ this._rendering = false
183
+ }
184
+ }
185
+
186
+ async _rerenderAllPages() {
187
+ // Remember scroll position relative to current page
188
+ const scrollRatio = this._getScrollRatio()
189
+
190
+ await this._renderAllPages()
191
+
192
+ // Restore approximate scroll position
193
+ this._restoreScrollRatio(scrollRatio)
194
+
195
+ // Notify highlight controller to re-render overlays at new scale
196
+ this.dispatch("pages-rerendered", { prefix: "highlite" })
197
+ }
198
+
199
+ _getScrollRatio() {
200
+ const container = this.pagesContainerTarget
201
+ if (!container.scrollHeight) return 0
202
+ return container.scrollTop / container.scrollHeight
203
+ }
204
+
205
+ _restoreScrollRatio(ratio) {
206
+ const container = this.pagesContainerTarget
207
+ container.scrollTop = ratio * container.scrollHeight
208
+ }
209
+
210
+ // ---------------------------------------------------------------------------
211
+ // Scroll tracking with IntersectionObserver
212
+ // ---------------------------------------------------------------------------
213
+
214
+ _setupIntersectionObserver() {
215
+ if (this._observer) {
216
+ this._observer.disconnect()
217
+ }
218
+
219
+ this._observer = new IntersectionObserver(
220
+ (entries) => {
221
+ // Find the most visible page
222
+ let maxRatio = 0
223
+ let visiblePage = this.currentPage
224
+
225
+ for (const entry of entries) {
226
+ if (entry.intersectionRatio > maxRatio) {
227
+ maxRatio = entry.intersectionRatio
228
+ visiblePage = parseInt(entry.target.dataset.pageNumber, 10)
229
+ }
230
+ }
231
+
232
+ if (visiblePage !== this.currentPage) {
233
+ this.currentPage = visiblePage
234
+ this._updatePageInfo(visiblePage)
235
+
236
+ this.dispatch("page-changed", {
237
+ detail: { page: visiblePage, totalPages: this.pageCount },
238
+ prefix: "highlite",
239
+ })
240
+ }
241
+ },
242
+ {
243
+ root: this.pagesContainerTarget,
244
+ threshold: [0, 0.25, 0.5, 0.75, 1.0],
245
+ }
246
+ )
247
+
248
+ for (const { wrapper } of this._pageElements) {
249
+ this._observer.observe(wrapper)
250
+ }
251
+ }
252
+
253
+ // ---------------------------------------------------------------------------
254
+ // Zoom controls (actions)
255
+ // ---------------------------------------------------------------------------
256
+
257
+ zoomIn() {
258
+ this.scaleValue = Math.min(this.scaleValue + 0.25, 5.0)
259
+ }
260
+
261
+ zoomOut() {
262
+ this.scaleValue = Math.max(this.scaleValue - 0.25, 0.5)
263
+ }
264
+
265
+ /**
266
+ * Fit the PDF page width to the container width.
267
+ */
268
+ async zoomFit() {
269
+ if (!this.renderer || this.pageCount === 0) return
270
+
271
+ const viewport = await this.renderer.getViewport(1, 1.0)
272
+ const containerWidth = this.pagesContainerTarget.clientWidth - 32 // account for padding
273
+ this.scaleValue = containerWidth / viewport.width
274
+ }
275
+
276
+ zoomAuto() {
277
+ this.scaleValue = 1.5
278
+ }
279
+
280
+ // ---------------------------------------------------------------------------
281
+ // Page navigation (action)
282
+ // ---------------------------------------------------------------------------
283
+
284
+ /**
285
+ * Scroll to a specific page. Can be called as an action with params
286
+ * or directly with a page number argument.
287
+ * @param {Event|number} eventOrPage
288
+ */
289
+ scrollToPage(eventOrPage) {
290
+ let pageNum
291
+
292
+ if (typeof eventOrPage === "number") {
293
+ pageNum = eventOrPage
294
+ } else if (eventOrPage?.params?.page) {
295
+ pageNum = eventOrPage.params.page
296
+ } else {
297
+ return
298
+ }
299
+
300
+ const index = pageNum - 1
301
+ if (index < 0 || index >= this._pageElements.length) return
302
+
303
+ this._pageElements[index].wrapper.scrollIntoView({ behavior: "smooth", block: "start" })
304
+ }
305
+
306
+ // ---------------------------------------------------------------------------
307
+ // Keyboard navigation
308
+ // ---------------------------------------------------------------------------
309
+
310
+ _onKeydown(event) {
311
+ // Only handle if viewer is visible / focused area
312
+ if (!this.element.contains(document.activeElement) && document.activeElement !== document.body) {
313
+ return
314
+ }
315
+
316
+ const ctrl = event.ctrlKey || event.metaKey
317
+
318
+ if (ctrl && (event.key === "=" || event.key === "+")) {
319
+ event.preventDefault()
320
+ this.zoomIn()
321
+ } else if (ctrl && event.key === "-") {
322
+ event.preventDefault()
323
+ this.zoomOut()
324
+ } else if (ctrl && event.key === "0") {
325
+ event.preventDefault()
326
+ this.zoomAuto()
327
+ }
328
+ }
329
+
330
+ // ---------------------------------------------------------------------------
331
+ // UI updates
332
+ // ---------------------------------------------------------------------------
333
+
334
+ _updatePageInfo(page) {
335
+ if (this.hasPageInfoTarget) {
336
+ this.pageInfoTarget.textContent = `Page ${page} of ${this.pageCount}`
337
+ }
338
+ }
339
+
340
+ _updateZoomDisplay() {
341
+ if (this.hasZoomLevelTarget) {
342
+ this.zoomLevelTarget.textContent = `${Math.round(this.scaleValue * 100)}%`
343
+ }
344
+ }
345
+
346
+ _showLoader() {
347
+ if (this.hasLoaderTarget) {
348
+ this.loaderTarget.classList.remove("hidden")
349
+ }
350
+ }
351
+
352
+ _hideLoader() {
353
+ if (this.hasLoaderTarget) {
354
+ this.loaderTarget.classList.add("hidden")
355
+ }
356
+ }
357
+
358
+ _showError(message) {
359
+ const container = this.pagesContainerTarget
360
+ container.innerHTML = `
361
+ <div class="highlite-error">
362
+ <p>Failed to load PDF</p>
363
+ <p class="highlite-error-detail">${this._escapeHtml(message)}</p>
364
+ </div>
365
+ `
366
+ }
367
+
368
+ _escapeHtml(text) {
369
+ const div = document.createElement("div")
370
+ div.textContent = text
371
+ return div.innerHTML
372
+ }
373
+ }
@@ -0,0 +1,30 @@
1
+ import { Application } from "@hotwired/stimulus"
2
+ import ViewerController from "highlite/controllers/viewer_controller"
3
+ import HighlightController from "highlite/controllers/highlight_controller"
4
+ import SidebarController from "highlite/controllers/sidebar_controller"
5
+ import HighlightsPanelController from "highlite/controllers/highlights_panel_controller"
6
+
7
+ /**
8
+ * Register all highlite Stimulus controllers with the given application.
9
+ *
10
+ * Controllers are registered with a "highlite--" prefix namespace so they
11
+ * don't collide with host app controllers.
12
+ *
13
+ * @param {Application} application - Stimulus Application instance
14
+ */
15
+ export function registerControllers(application) {
16
+ application.register("highlite--viewer", ViewerController)
17
+ application.register("highlite--highlight", HighlightController)
18
+ application.register("highlite--sidebar", SidebarController)
19
+ application.register("highlite--highlights-panel", HighlightsPanelController)
20
+ }
21
+
22
+ // Auto-register if a Stimulus application already exists on the window,
23
+ // or start a fresh one. This allows the gem to work out of the box
24
+ // when loaded via importmap without manual registration.
25
+ const application = window.Stimulus || Application.start()
26
+ registerControllers(application)
27
+
28
+ export { ViewerController, HighlightController, SidebarController, HighlightsPanelController }
29
+ export { PdfRenderer } from "highlite/lib/pdf_renderer"
30
+ export { HighlightStore } from "highlite/lib/highlight_store"
File without changes
@@ -0,0 +1,235 @@
1
+ /**
2
+ * HighlightStore — State management for highlights with localStorage persistence.
3
+ *
4
+ * Manages a collection of highlights for a specific document, persisting them
5
+ * to localStorage and dispatching DOM events when highlights change.
6
+ *
7
+ * @example
8
+ * const store = new HighlightStore("doc-123")
9
+ * store.setEventTarget(viewerElement)
10
+ * store.load()
11
+ *
12
+ * store.add({ page: 1, type: "text", color: "#ffd54f", rects: [...], text: "Hello" })
13
+ * const pageHighlights = store.getByPage(1)
14
+ */
15
+
16
+ const STORAGE_PREFIX = "highlite-"
17
+
18
+ export class HighlightStore {
19
+ /**
20
+ * @param {string} documentId - Unique identifier for the document
21
+ */
22
+ constructor(documentId) {
23
+ this.documentId = documentId
24
+ this.storageKey = `${STORAGE_PREFIX}${documentId}`
25
+ this._highlights = new Map()
26
+ this._eventTarget = null
27
+ }
28
+
29
+ /**
30
+ * Set the DOM element that will receive highlight events.
31
+ * @param {HTMLElement} element
32
+ */
33
+ setEventTarget(element) {
34
+ this._eventTarget = element
35
+ }
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // CRUD
39
+ // ---------------------------------------------------------------------------
40
+
41
+ /**
42
+ * Add a new highlight. Assigns an id and createdAt timestamp.
43
+ * @param {Object} highlight
44
+ * @param {number} highlight.page - Page number (1-based)
45
+ * @param {string} highlight.type - "text" or "area"
46
+ * @param {string} highlight.color - CSS color string
47
+ * @param {Array<{x: number, y: number, w: number, h: number}>} highlight.rects
48
+ * @param {string} [highlight.text] - Selected text content
49
+ * @returns {Object} The created highlight with id and createdAt
50
+ */
51
+ add(highlight) {
52
+ const entry = {
53
+ id: crypto.randomUUID(),
54
+ page: highlight.page,
55
+ type: highlight.type || "text",
56
+ color: highlight.color || "#ffd54f",
57
+ rects: highlight.rects || [],
58
+ text: highlight.text || "",
59
+ note: highlight.note || "",
60
+ createdAt: new Date().toISOString(),
61
+ }
62
+
63
+ this._highlights.set(entry.id, entry)
64
+ this.save()
65
+ this._dispatch("highlite:highlight-created", entry)
66
+
67
+ return entry
68
+ }
69
+
70
+ /**
71
+ * Remove a highlight by id.
72
+ * @param {string} id
73
+ * @returns {boolean} True if the highlight was found and removed
74
+ */
75
+ remove(id) {
76
+ const existed = this._highlights.delete(id)
77
+ if (existed) {
78
+ this.save()
79
+ this._dispatch("highlite:highlight-removed", { id })
80
+ }
81
+ return existed
82
+ }
83
+
84
+ /**
85
+ * Get all highlights grouped by page number.
86
+ * @returns {Object<number, Array>} Highlights keyed by page number
87
+ */
88
+ getAll() {
89
+ const grouped = {}
90
+ for (const highlight of this._highlights.values()) {
91
+ if (!grouped[highlight.page]) {
92
+ grouped[highlight.page] = []
93
+ }
94
+ grouped[highlight.page].push(highlight)
95
+ }
96
+ return grouped
97
+ }
98
+
99
+ /**
100
+ * Get highlights for a specific page.
101
+ * @param {number} page - 1-based page number
102
+ * @returns {Array} Highlights on the given page
103
+ */
104
+ getByPage(page) {
105
+ const results = []
106
+ for (const highlight of this._highlights.values()) {
107
+ if (highlight.page === page) {
108
+ results.push(highlight)
109
+ }
110
+ }
111
+ return results
112
+ }
113
+
114
+ /**
115
+ * Get a single highlight by id.
116
+ * @param {string} id
117
+ * @returns {Object|undefined}
118
+ */
119
+ getById(id) {
120
+ return this._highlights.get(id)
121
+ }
122
+
123
+ /**
124
+ * Clear highlights. If page is given, clear only that page; otherwise clear all.
125
+ * @param {number|null} [page=null]
126
+ */
127
+ clear(page = null) {
128
+ if (page !== null) {
129
+ for (const [id, highlight] of this._highlights) {
130
+ if (highlight.page === page) {
131
+ this._highlights.delete(id)
132
+ }
133
+ }
134
+ } else {
135
+ this._highlights.clear()
136
+ }
137
+
138
+ this.save()
139
+ this._dispatch("highlite:highlights-cleared", { page })
140
+ }
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // Search
144
+ // ---------------------------------------------------------------------------
145
+
146
+ /**
147
+ * Search highlights by text content (case-insensitive).
148
+ * @param {string} query
149
+ * @returns {Array} Matching highlights
150
+ */
151
+ search(query) {
152
+ const lower = query.toLowerCase()
153
+ const results = []
154
+ for (const highlight of this._highlights.values()) {
155
+ if (highlight.text && highlight.text.toLowerCase().includes(lower)) {
156
+ results.push(highlight)
157
+ }
158
+ }
159
+ return results
160
+ }
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // Selection (for UI interactions)
164
+ // ---------------------------------------------------------------------------
165
+
166
+ /**
167
+ * Mark a highlight as selected, dispatching a selection event.
168
+ * @param {string} id
169
+ */
170
+ select(id) {
171
+ const highlight = this._highlights.get(id)
172
+ if (highlight) {
173
+ this._dispatch("highlite:highlight-selected", {
174
+ id,
175
+ page: highlight.page,
176
+ })
177
+ }
178
+ }
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // Persistence
182
+ // ---------------------------------------------------------------------------
183
+
184
+ /**
185
+ * Persist all highlights to localStorage.
186
+ */
187
+ save() {
188
+ try {
189
+ const data = Array.from(this._highlights.values())
190
+ localStorage.setItem(this.storageKey, JSON.stringify(data))
191
+ } catch {
192
+ // localStorage may be full or unavailable; fail silently
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Load highlights from localStorage.
198
+ */
199
+ load() {
200
+ try {
201
+ const raw = localStorage.getItem(this.storageKey)
202
+ if (!raw) return
203
+
204
+ const data = JSON.parse(raw)
205
+ if (!Array.isArray(data)) return
206
+
207
+ this._highlights.clear()
208
+ for (const item of data) {
209
+ if (item.id && item.page) {
210
+ this._highlights.set(item.id, item)
211
+ }
212
+ }
213
+ } catch {
214
+ // Corrupted data; start fresh
215
+ this._highlights.clear()
216
+ }
217
+ }
218
+
219
+ // ---------------------------------------------------------------------------
220
+ // Internal
221
+ // ---------------------------------------------------------------------------
222
+
223
+ /**
224
+ * Dispatch a custom event on the event target element.
225
+ * @param {string} name - Event name
226
+ * @param {Object} detail - Event detail payload
227
+ */
228
+ _dispatch(name, detail) {
229
+ if (!this._eventTarget) return
230
+
231
+ this._eventTarget.dispatchEvent(
232
+ new CustomEvent(name, { detail, bubbles: true })
233
+ )
234
+ }
235
+ }