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,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
|
+
}
|