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,465 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { PdfRenderer } from "highlite/lib/pdf_renderer"
3
+
4
+ /**
5
+ * SidebarController — Stimulus controller for the left sidebar.
6
+ *
7
+ * Provides two tabbed views:
8
+ * - **Outline** — Hierarchical TOC extracted from PDF metadata
9
+ * - **Pages** — Thumbnail previews for each page
10
+ *
11
+ * Targets:
12
+ * outlineTab - Outline tab button
13
+ * pagesTab - Pages tab button
14
+ * outlinePanel - Outline content container
15
+ * pagesPanel - Pages/thumbnails content container
16
+ * thumbnailsContainer - Container for thumbnail canvases
17
+ * pageCounter - "Page X of Y" display
18
+ *
19
+ * Listens for:
20
+ * highlite:document-loaded — receive outline + page count
21
+ * highlite:page-changed — update active thumbnail
22
+ * highlite:highlight-created/removed/cleared — update outline dots
23
+ */
24
+ export default class extends Controller {
25
+ static targets = [
26
+ "outlineTab",
27
+ "pagesTab",
28
+ "outlinePanel",
29
+ "pagesPanel",
30
+ "thumbnailsContainer",
31
+ "pageCounter",
32
+ ]
33
+
34
+ connect() {
35
+ this._pageCount = 0
36
+ this._outline = []
37
+ this._activePage = 1
38
+ this._renderer = null
39
+ this._thumbnailObserver = null
40
+ this._renderedThumbnails = new Set()
41
+
42
+ // Bind event handlers
43
+ this._onDocumentLoaded = this._handleDocumentLoaded.bind(this)
44
+ this._onPageChanged = this._handlePageChanged.bind(this)
45
+ this._onHighlightChanged = this._handleHighlightChanged.bind(this)
46
+
47
+ // Listen on the parent viewer element
48
+ const viewer = this._getViewerElement()
49
+ if (viewer) {
50
+ viewer.addEventListener(
51
+ "highlite:document-loaded",
52
+ this._onDocumentLoaded
53
+ )
54
+ viewer.addEventListener(
55
+ "highlite:page-changed",
56
+ this._onPageChanged
57
+ )
58
+ viewer.addEventListener(
59
+ "highlite:highlight-created",
60
+ this._onHighlightChanged
61
+ )
62
+ viewer.addEventListener(
63
+ "highlite:highlight-removed",
64
+ this._onHighlightChanged
65
+ )
66
+ viewer.addEventListener(
67
+ "highlite:highlights-cleared",
68
+ this._onHighlightChanged
69
+ )
70
+ }
71
+ }
72
+
73
+ disconnect() {
74
+ const viewer = this._getViewerElement()
75
+ if (viewer) {
76
+ viewer.removeEventListener(
77
+ "highlite:document-loaded",
78
+ this._onDocumentLoaded
79
+ )
80
+ viewer.removeEventListener(
81
+ "highlite:page-changed",
82
+ this._onPageChanged
83
+ )
84
+ viewer.removeEventListener(
85
+ "highlite:highlight-created",
86
+ this._onHighlightChanged
87
+ )
88
+ viewer.removeEventListener(
89
+ "highlite:highlight-removed",
90
+ this._onHighlightChanged
91
+ )
92
+ viewer.removeEventListener(
93
+ "highlite:highlights-cleared",
94
+ this._onHighlightChanged
95
+ )
96
+ }
97
+
98
+ if (this._thumbnailObserver) {
99
+ this._thumbnailObserver.disconnect()
100
+ }
101
+ }
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Actions — Tab switching
105
+ // ---------------------------------------------------------------------------
106
+
107
+ /**
108
+ * Show the Outline tab.
109
+ */
110
+ showOutline() {
111
+ this._setActiveTab("outline")
112
+ }
113
+
114
+ /**
115
+ * Show the Pages tab.
116
+ */
117
+ showPages() {
118
+ this._setActiveTab("pages")
119
+ }
120
+
121
+ _setActiveTab(tab) {
122
+ const isOutline = tab === "outline"
123
+
124
+ if (this.hasOutlineTabTarget) {
125
+ this.outlineTabTarget.classList.toggle(
126
+ "highlite-tab-active",
127
+ isOutline
128
+ )
129
+ }
130
+ if (this.hasPagesTabTarget) {
131
+ this.pagesTabTarget.classList.toggle(
132
+ "highlite-tab-active",
133
+ !isOutline
134
+ )
135
+ }
136
+ if (this.hasOutlinePanelTarget) {
137
+ this.outlinePanelTarget.classList.toggle("hidden", !isOutline)
138
+ }
139
+ if (this.hasPagesPanelTarget) {
140
+ this.pagesPanelTarget.classList.toggle("hidden", isOutline)
141
+ }
142
+ }
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // Event handlers
146
+ // ---------------------------------------------------------------------------
147
+
148
+ /**
149
+ * Handle document loaded event — build outline and thumbnails.
150
+ * @param {CustomEvent} event
151
+ */
152
+ _handleDocumentLoaded(event) {
153
+ const { pageCount, outline } = event.detail
154
+ this._pageCount = pageCount
155
+ this._outline = outline || []
156
+
157
+ this._renderOutline()
158
+ this._createThumbnails()
159
+ this._updatePageCounter(1)
160
+ }
161
+
162
+ /**
163
+ * Handle page changed event — update active thumbnail.
164
+ * @param {CustomEvent} event
165
+ */
166
+ _handlePageChanged(event) {
167
+ const { page } = event.detail
168
+ this._activePage = page
169
+ this._updateActiveThumbnail(page)
170
+ this._updatePageCounter(page)
171
+ }
172
+
173
+ /**
174
+ * Handle highlight changes — update outline dots.
175
+ */
176
+ _handleHighlightChanged() {
177
+ this._updateOutlineDots()
178
+ }
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // Outline rendering
182
+ // ---------------------------------------------------------------------------
183
+
184
+ _renderOutline() {
185
+ if (!this.hasOutlinePanelTarget) return
186
+
187
+ if (this._outline.length === 0) {
188
+ this.outlinePanelTarget.innerHTML =
189
+ '<p class="highlite-sidebar-empty">No outline available</p>'
190
+ return
191
+ }
192
+
193
+ const list = this._buildOutlineList(this._outline)
194
+ this.outlinePanelTarget.innerHTML = ""
195
+ this.outlinePanelTarget.appendChild(list)
196
+ }
197
+
198
+ /**
199
+ * Recursively build the outline tree as nested lists.
200
+ * @param {Array} items
201
+ * @param {number} [depth=0]
202
+ * @returns {HTMLElement}
203
+ */
204
+ _buildOutlineList(items, depth = 0) {
205
+ const ul = document.createElement("ul")
206
+ ul.className = "highlite-outline-list"
207
+ if (depth > 0) ul.style.paddingLeft = "1rem"
208
+
209
+ for (const item of items) {
210
+ const li = document.createElement("li")
211
+ li.className = "highlite-outline-item"
212
+
213
+ const link = document.createElement("button")
214
+ link.className = "highlite-outline-link"
215
+ link.type = "button"
216
+
217
+ // Title
218
+ const titleSpan = document.createElement("span")
219
+ titleSpan.className = "highlite-outline-title"
220
+ titleSpan.textContent = item.title
221
+
222
+ // Highlight dot (hidden by default)
223
+ const dot = document.createElement("span")
224
+ dot.className = "highlite-outline-dot"
225
+ dot.style.display = "none"
226
+ if (item.pageNum) dot.dataset.outlinePage = item.pageNum
227
+
228
+ // Page number
229
+ const pageSpan = document.createElement("span")
230
+ pageSpan.className = "highlite-outline-page"
231
+ pageSpan.textContent = item.pageNum || ""
232
+
233
+ link.appendChild(dot)
234
+ link.appendChild(titleSpan)
235
+ link.appendChild(pageSpan)
236
+
237
+ if (item.pageNum) {
238
+ link.addEventListener("click", () => {
239
+ this._navigateToPage(item.pageNum)
240
+ })
241
+ }
242
+
243
+ li.appendChild(link)
244
+
245
+ // Render children
246
+ if (item.items && item.items.length > 0) {
247
+ const details = document.createElement("details")
248
+ details.open = depth < 1 // Auto-expand first level
249
+ const summary = document.createElement("summary")
250
+ summary.appendChild(link)
251
+ details.appendChild(summary)
252
+ details.appendChild(this._buildOutlineList(item.items, depth + 1))
253
+ li.innerHTML = ""
254
+ li.appendChild(details)
255
+ }
256
+
257
+ ul.appendChild(li)
258
+ }
259
+
260
+ return ul
261
+ }
262
+
263
+ /**
264
+ * Update blue dots on outline items that have highlights on their page.
265
+ */
266
+ _updateOutlineDots() {
267
+ const dots = this.element.querySelectorAll(
268
+ ".highlite-outline-dot[data-outline-page]"
269
+ )
270
+ // Get highlight store from viewer element
271
+ const viewer = this._getViewerElement()
272
+ if (!viewer) return
273
+
274
+ for (const dot of dots) {
275
+ const page = parseInt(dot.dataset.outlinePage, 10)
276
+ const highlights = viewer.querySelectorAll(
277
+ `.highlite-page[data-page-number="${page}"] .highlite-highlight`
278
+ )
279
+ dot.style.display = highlights.length > 0 ? "inline-block" : "none"
280
+ }
281
+ }
282
+
283
+ // ---------------------------------------------------------------------------
284
+ // Thumbnail rendering
285
+ // ---------------------------------------------------------------------------
286
+
287
+ _createThumbnails() {
288
+ if (!this.hasThumbnailsContainerTarget) return
289
+
290
+ const container = this.thumbnailsContainerTarget
291
+ container.innerHTML = ""
292
+ this._renderedThumbnails.clear()
293
+
294
+ for (let i = 1; i <= this._pageCount; i++) {
295
+ const wrapper = document.createElement("button")
296
+ wrapper.type = "button"
297
+ wrapper.className = "highlite-thumbnail"
298
+ wrapper.dataset.pageNumber = i
299
+ if (i === 1) wrapper.classList.add("highlite-thumbnail-active")
300
+
301
+ const canvas = document.createElement("canvas")
302
+ canvas.className = "highlite-thumbnail-canvas"
303
+ canvas.width = 1
304
+ canvas.height = 1
305
+
306
+ const label = document.createElement("span")
307
+ label.className = "highlite-thumbnail-label"
308
+ label.textContent = i
309
+
310
+ wrapper.appendChild(canvas)
311
+ wrapper.appendChild(label)
312
+
313
+ wrapper.addEventListener("click", () => {
314
+ this._navigateToPage(i)
315
+ })
316
+
317
+ container.appendChild(wrapper)
318
+ }
319
+
320
+ // Lazy-load thumbnails with IntersectionObserver
321
+ this._setupThumbnailObserver()
322
+ }
323
+
324
+ _setupThumbnailObserver() {
325
+ if (this._thumbnailObserver) {
326
+ this._thumbnailObserver.disconnect()
327
+ }
328
+
329
+ this._thumbnailObserver = new IntersectionObserver(
330
+ (entries) => {
331
+ for (const entry of entries) {
332
+ if (!entry.isIntersecting) continue
333
+
334
+ const pageNum = parseInt(entry.target.dataset.pageNumber, 10)
335
+ if (this._renderedThumbnails.has(pageNum)) continue
336
+
337
+ this._renderedThumbnails.add(pageNum)
338
+ this._renderThumbnail(pageNum, entry.target)
339
+ }
340
+ },
341
+ { root: this.hasThumbnailsContainerTarget ? this.thumbnailsContainerTarget : null }
342
+ )
343
+
344
+ const thumbnails = this.thumbnailsContainerTarget.querySelectorAll(
345
+ ".highlite-thumbnail"
346
+ )
347
+ for (const thumb of thumbnails) {
348
+ this._thumbnailObserver.observe(thumb)
349
+ }
350
+ }
351
+
352
+ /**
353
+ * Render a single thumbnail by finding the viewer's PdfRenderer.
354
+ * @param {number} pageNum
355
+ * @param {HTMLElement} wrapper
356
+ */
357
+ async _renderThumbnail(pageNum, wrapper) {
358
+ const canvas = wrapper.querySelector("canvas")
359
+ if (!canvas) return
360
+
361
+ // Get the renderer from the viewer controller
362
+ const viewer = this._getViewerElement()
363
+ if (!viewer) return
364
+
365
+ // Access the Stimulus controller instance to use its renderer
366
+ const app = window.Stimulus || this.application
367
+ const viewerController = app?.getControllerForElementAndIdentifier?.(
368
+ viewer,
369
+ "highlite--viewer"
370
+ )
371
+
372
+ if (viewerController?.renderer) {
373
+ try {
374
+ // Calculate scale to fill sidebar width
375
+ const containerWidth = this.thumbnailsContainerTarget.clientWidth - 8 // account for border/padding
376
+ const viewport = await viewerController.renderer.getViewport(pageNum, 1.0)
377
+ const scale = containerWidth / viewport.width
378
+ await viewerController.renderer.renderThumbnail(pageNum, canvas, scale)
379
+ // Clear inline dimensions set by renderPage so CSS width:100% takes effect
380
+ canvas.style.width = ""
381
+ canvas.style.height = ""
382
+ } catch {
383
+ // Thumbnail rendering may fail for some pages; degrade gracefully
384
+ }
385
+ }
386
+ }
387
+
388
+ /**
389
+ * Update which thumbnail has the active border.
390
+ * @param {number} page
391
+ */
392
+ _updateActiveThumbnail(page) {
393
+ if (!this.hasThumbnailsContainerTarget) return
394
+
395
+ const thumbnails = this.thumbnailsContainerTarget.querySelectorAll(
396
+ ".highlite-thumbnail"
397
+ )
398
+
399
+ for (const thumb of thumbnails) {
400
+ const num = parseInt(thumb.dataset.pageNumber, 10)
401
+ thumb.classList.toggle("highlite-thumbnail-active", num === page)
402
+ }
403
+
404
+ // Scroll active thumbnail into view
405
+ const active = this.thumbnailsContainerTarget.querySelector(
406
+ ".highlite-thumbnail-active"
407
+ )
408
+ if (active) {
409
+ active.scrollIntoView({ block: "nearest", behavior: "smooth" })
410
+ }
411
+ }
412
+
413
+ // ---------------------------------------------------------------------------
414
+ // Navigation
415
+ // ---------------------------------------------------------------------------
416
+
417
+ /**
418
+ * Navigate to a page by dispatching to the viewer controller.
419
+ * @param {number} pageNum
420
+ */
421
+ _navigateToPage(pageNum) {
422
+ const viewer = this._getViewerElement()
423
+ if (!viewer) return
424
+
425
+ // Access the viewer controller to call scrollToPage
426
+ const app = window.Stimulus || this.application
427
+ const viewerController = app?.getControllerForElementAndIdentifier?.(
428
+ viewer,
429
+ "highlite--viewer"
430
+ )
431
+
432
+ if (viewerController) {
433
+ viewerController.scrollToPage(pageNum)
434
+ }
435
+ }
436
+
437
+ // ---------------------------------------------------------------------------
438
+ // UI updates
439
+ // ---------------------------------------------------------------------------
440
+
441
+ _updatePageCounter(page) {
442
+ if (this.hasPageCounterTarget) {
443
+ this.pageCounterTarget.textContent = `Page ${page} of ${this._pageCount}`
444
+ }
445
+ }
446
+
447
+ // ---------------------------------------------------------------------------
448
+ // Helpers
449
+ // ---------------------------------------------------------------------------
450
+
451
+ /**
452
+ * Find the viewer element in the DOM.
453
+ * @returns {HTMLElement|null}
454
+ */
455
+ _getViewerElement() {
456
+ return (
457
+ this.element.closest(
458
+ "[data-controller*='highlite--viewer']"
459
+ ) ||
460
+ document.querySelector(
461
+ "[data-controller*='highlite--viewer']"
462
+ )
463
+ )
464
+ }
465
+ }