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.
- checksums.yaml +7 -0
- data/.claude/commands/publish.md +45 -0
- data/README.md +176 -0
- data/Rakefile +12 -0
- data/app/assets/stylesheets/highlite/viewer.css +607 -0
- data/app/helpers/highlite/application_helper.rb +20 -0
- data/app/javascript/highlite/controllers/.keep +0 -0
- data/app/javascript/highlite/controllers/highlight_controller.js +829 -0
- data/app/javascript/highlite/controllers/highlights_panel_controller.js +313 -0
- data/app/javascript/highlite/controllers/sidebar_controller.js +465 -0
- data/app/javascript/highlite/controllers/viewer_controller.js +373 -0
- data/app/javascript/highlite/index.js +30 -0
- data/app/javascript/highlite/lib/.keep +0 -0
- data/app/javascript/highlite/lib/highlight_store.js +235 -0
- data/app/javascript/highlite/lib/pdf_renderer.js +212 -0
- data/app/views/highlite/_floating_toolbar.html.erb +79 -0
- data/app/views/highlite/_left_sidebar.html.erb +43 -0
- data/app/views/highlite/_right_sidebar.html.erb +56 -0
- data/app/views/highlite/_toolbar.html.erb +63 -0
- data/app/views/highlite/_viewer.html.erb +63 -0
- data/config/importmap.rb +17 -0
- data/lib/generators/highlite/install/install_generator.rb +44 -0
- data/lib/generators/highlite/install/templates/_right_sidebar.html.erb.tt +16 -0
- data/lib/generators/highlite/install/templates/initializer.rb.tt +20 -0
- data/lib/highlite/configuration.rb +27 -0
- data/lib/highlite/engine.rb +26 -0
- data/lib/highlite/version.rb +5 -0
- data/lib/highlite.rb +17 -0
- data/sig/highlite.rbs +4 -0
- data/tasks/todo.md +129 -0
- data/test/dummy/Rakefile +3 -0
- data/test/dummy/app/controllers/application_controller.rb +2 -0
- data/test/dummy/app/controllers/documents_controller.rb +6 -0
- data/test/dummy/app/javascript/application.js +1 -0
- data/test/dummy/app/views/documents/show.html.erb +1 -0
- data/test/dummy/app/views/layouts/application.html.erb +13 -0
- data/test/dummy/bin/rails +4 -0
- data/test/dummy/config/application.rb +15 -0
- data/test/dummy/config/boot.rb +2 -0
- data/test/dummy/config/environment.rb +2 -0
- data/test/dummy/config/importmap.rb +3 -0
- data/test/dummy/config/routes.rb +3 -0
- data/test/dummy/config.ru +2 -0
- data/test/dummy/public/convention-over-configuration.pdf +0 -0
- 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
|
+
}
|