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,313 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { HighlightStore } from "highlite/lib/highlight_store"
3
+
4
+ /**
5
+ * HighlightsPanelController — Stimulus controller for the default right sidebar.
6
+ *
7
+ * Displays all highlights grouped by page, with search/filter and clear-all
8
+ * functionality. Updates in real-time as highlights are added/removed.
9
+ *
10
+ * Targets:
11
+ * list - Container for the highlight cards
12
+ * searchInput - Text input for filtering highlights
13
+ * count - Element displaying "N annotation(s)"
14
+ * clearBtn - Clear all button
15
+ *
16
+ * Values:
17
+ * documentId (String) - Document ID for highlight store
18
+ */
19
+ export default class extends Controller {
20
+ static targets = ["list", "searchInput", "count", "clearBtn"]
21
+
22
+ static values = {
23
+ documentId: String,
24
+ }
25
+
26
+ connect() {
27
+ this.store = new HighlightStore(this.documentIdValue)
28
+ this.store.setEventTarget(this._getViewerElement() || this.element)
29
+ this.store.load()
30
+
31
+ this._searchQuery = ""
32
+
33
+ // Bind event handlers
34
+ this._onHighlightCreated = this._handleHighlightChange.bind(this)
35
+ this._onHighlightRemoved = this._handleHighlightChange.bind(this)
36
+ this._onHighlightsCleared = this._handleHighlightChange.bind(this)
37
+
38
+ const viewer = this._getViewerElement()
39
+ const target = viewer || this.element
40
+
41
+ target.addEventListener(
42
+ "highlite:highlight-created",
43
+ this._onHighlightCreated
44
+ )
45
+ target.addEventListener(
46
+ "highlite:highlight-removed",
47
+ this._onHighlightRemoved
48
+ )
49
+ target.addEventListener(
50
+ "highlite:highlights-cleared",
51
+ this._onHighlightsCleared
52
+ )
53
+
54
+ this._renderList()
55
+ }
56
+
57
+ disconnect() {
58
+ const viewer = this._getViewerElement()
59
+ const target = viewer || this.element
60
+
61
+ target.removeEventListener(
62
+ "highlite:highlight-created",
63
+ this._onHighlightCreated
64
+ )
65
+ target.removeEventListener(
66
+ "highlite:highlight-removed",
67
+ this._onHighlightRemoved
68
+ )
69
+ target.removeEventListener(
70
+ "highlite:highlights-cleared",
71
+ this._onHighlightsCleared
72
+ )
73
+ }
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Actions
77
+ // ---------------------------------------------------------------------------
78
+
79
+ /**
80
+ * Filter highlights by search query (called on input event).
81
+ */
82
+ search() {
83
+ this._searchQuery = this.hasSearchInputTarget
84
+ ? this.searchInputTarget.value.trim()
85
+ : ""
86
+ this._renderList()
87
+ }
88
+
89
+ /**
90
+ * Clear all highlights.
91
+ */
92
+ clearAll() {
93
+ this.store.clear()
94
+ this._renderList()
95
+ }
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Event handlers
99
+ // ---------------------------------------------------------------------------
100
+
101
+ _handleHighlightChange() {
102
+ // Reload from localStorage to pick up changes from other controllers
103
+ this.store.load()
104
+ this._renderList()
105
+ }
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // Rendering
109
+ // ---------------------------------------------------------------------------
110
+
111
+ _renderList() {
112
+ if (!this.hasListTarget) return
113
+
114
+ const highlights = this._searchQuery
115
+ ? this.store.search(this._searchQuery)
116
+ : this._getAllFlat()
117
+
118
+ this._updateCount(highlights.length)
119
+
120
+ if (highlights.length === 0) {
121
+ this.listTarget.innerHTML = this._emptyStateHtml()
122
+ return
123
+ }
124
+
125
+ // Group by page
126
+ const grouped = this._groupByPage(highlights)
127
+ const pages = Object.keys(grouped)
128
+ .map(Number)
129
+ .sort((a, b) => a - b)
130
+
131
+ const fragment = document.createDocumentFragment()
132
+
133
+ for (const page of pages) {
134
+ // Page header
135
+ const header = document.createElement("div")
136
+ header.className = "highlite-panel-page-header"
137
+ header.textContent = `Page ${page}`
138
+ fragment.appendChild(header)
139
+
140
+ // Highlight cards for this page
141
+ for (const highlight of grouped[page]) {
142
+ fragment.appendChild(this._buildHighlightCard(highlight))
143
+ }
144
+ }
145
+
146
+ this.listTarget.innerHTML = ""
147
+ this.listTarget.appendChild(fragment)
148
+ }
149
+
150
+ /**
151
+ * Build a single highlight card element.
152
+ * @param {Object} highlight
153
+ * @returns {HTMLElement}
154
+ */
155
+ _buildHighlightCard(highlight) {
156
+ const card = document.createElement("button")
157
+ card.type = "button"
158
+ card.className = "highlite-panel-card"
159
+ card.dataset.highlightId = highlight.id
160
+
161
+ // Color swatch
162
+ const swatch = document.createElement("span")
163
+ swatch.className = "highlite-panel-swatch"
164
+ swatch.style.backgroundColor = highlight.color
165
+
166
+ // Type badge
167
+ const badge = document.createElement("span")
168
+ badge.className = "highlite-panel-badge"
169
+ badge.textContent = highlight.type === "area" ? "Area" : "Text"
170
+
171
+ // Quoted text (truncated)
172
+ const text = document.createElement("span")
173
+ text.className = "highlite-panel-text"
174
+ text.textContent = highlight.text
175
+ ? this._truncate(highlight.text, 80)
176
+ : "(area selection)"
177
+
178
+ // Timestamp
179
+ const time = document.createElement("span")
180
+ time.className = "highlite-panel-time"
181
+ time.textContent = this._formatTime(highlight.createdAt)
182
+
183
+ // Delete button
184
+ const deleteBtn = document.createElement("button")
185
+ deleteBtn.type = "button"
186
+ deleteBtn.className = "highlite-panel-delete"
187
+ deleteBtn.title = "Delete highlight"
188
+ deleteBtn.innerHTML =
189
+ '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>'
190
+ deleteBtn.addEventListener("click", (e) => {
191
+ e.stopPropagation()
192
+ this.store.remove(highlight.id)
193
+ })
194
+
195
+ // Header row: swatch + badge + time + delete
196
+ const headerRow = document.createElement("div")
197
+ headerRow.className = "highlite-panel-card-header"
198
+ headerRow.appendChild(swatch)
199
+ headerRow.appendChild(badge)
200
+ headerRow.appendChild(time)
201
+ headerRow.appendChild(deleteBtn)
202
+
203
+ card.appendChild(headerRow)
204
+ card.appendChild(text)
205
+
206
+ // Note (if present)
207
+ if (highlight.note) {
208
+ const note = document.createElement("p")
209
+ note.className = "highlite-panel-note"
210
+ note.textContent = this._truncate(highlight.note, 120)
211
+ card.appendChild(note)
212
+ }
213
+
214
+ // Click to navigate to highlight and select it
215
+ card.addEventListener("click", () => {
216
+ this._navigateToHighlight(highlight)
217
+ })
218
+
219
+ return card
220
+ }
221
+
222
+ // ---------------------------------------------------------------------------
223
+ // Navigation
224
+ // ---------------------------------------------------------------------------
225
+
226
+ _navigateToHighlight(highlight) {
227
+ const viewer = this._getViewerElement()
228
+ if (!viewer) return
229
+
230
+ // Scroll to the page
231
+ const app = window.Stimulus || this.application
232
+ const viewerController = app?.getControllerForElementAndIdentifier?.(
233
+ viewer,
234
+ "highlite--viewer"
235
+ )
236
+
237
+ if (viewerController) {
238
+ viewerController.scrollToPage(highlight.page)
239
+ }
240
+
241
+ // Select the highlight
242
+ this.store.select(highlight.id)
243
+ }
244
+
245
+ // ---------------------------------------------------------------------------
246
+ // UI updates
247
+ // ---------------------------------------------------------------------------
248
+
249
+ _updateCount(count) {
250
+ if (this.hasCountTarget) {
251
+ this.countTarget.textContent = `${count} annotation${count !== 1 ? "s" : ""}`
252
+ }
253
+
254
+ if (this.hasClearBtnTarget) {
255
+ this.clearBtnTarget.disabled = count === 0
256
+ }
257
+ }
258
+
259
+ _emptyStateHtml() {
260
+ if (this._searchQuery) {
261
+ return '<p class="highlite-panel-empty">No matching annotations</p>'
262
+ }
263
+ return '<p class="highlite-panel-empty">No annotations yet. Select text or draw an area to highlight.</p>'
264
+ }
265
+
266
+ // ---------------------------------------------------------------------------
267
+ // Helpers
268
+ // ---------------------------------------------------------------------------
269
+
270
+ _getAllFlat() {
271
+ const grouped = this.store.getAll()
272
+ const flat = []
273
+ for (const page of Object.keys(grouped)) {
274
+ flat.push(...grouped[page])
275
+ }
276
+ return flat
277
+ }
278
+
279
+ _groupByPage(highlights) {
280
+ const grouped = {}
281
+ for (const h of highlights) {
282
+ if (!grouped[h.page]) grouped[h.page] = []
283
+ grouped[h.page].push(h)
284
+ }
285
+ return grouped
286
+ }
287
+
288
+ _truncate(text, maxLen) {
289
+ if (text.length <= maxLen) return text
290
+ return text.substring(0, maxLen) + "..."
291
+ }
292
+
293
+ _formatTime(isoString) {
294
+ if (!isoString) return ""
295
+ try {
296
+ const date = new Date(isoString)
297
+ return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
298
+ } catch {
299
+ return ""
300
+ }
301
+ }
302
+
303
+ _getViewerElement() {
304
+ return (
305
+ this.element.closest(
306
+ "[data-controller*='highlite--viewer']"
307
+ ) ||
308
+ document.querySelector(
309
+ "[data-controller*='highlite--viewer']"
310
+ )
311
+ )
312
+ }
313
+ }