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,829 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { HighlightStore } from "highlite/lib/highlight_store"
3
+
4
+ /**
5
+ * HighlightController — Stimulus controller for drawing and managing highlights.
6
+ *
7
+ * Handles text selection highlighting (via Selection API), area/rectangle
8
+ * highlighting (mousedown→drag→mouseup), and rendering highlight overlays
9
+ * on PDF pages.
10
+ *
11
+ * Values:
12
+ * documentId (String) - Document ID for highlight persistence
13
+ * activeColor (String) - Current highlight color (default "#ffd54f")
14
+ * activeTool (String) - Current tool: "select" | "text" | "area"
15
+ *
16
+ * Actions:
17
+ * setColor, setTool, clearPage, clearAll
18
+ *
19
+ * Listens for:
20
+ * highlite:document-loaded — initialize highlights for all pages
21
+ * highlite:page-changed — page-specific updates
22
+ */
23
+ export default class extends Controller {
24
+ static values = {
25
+ documentId: String,
26
+ activeColor: { type: String, default: "#ffd54f" },
27
+ activeTool: { type: String, default: "select" },
28
+ }
29
+
30
+ connect() {
31
+ this.store = new HighlightStore(this.documentIdValue)
32
+ this.store.setEventTarget(this.element)
33
+ this.store.load()
34
+
35
+ // Area drawing state
36
+ this._drawing = false
37
+ this._drawStart = null
38
+ this._drawPreview = null
39
+ this._drawPage = null
40
+
41
+ // Pending text selection state (for popup/dialog flow)
42
+ this._pendingSelection = null
43
+ this._popup = null
44
+ this._noteDialog = null
45
+
46
+ // Bind event handlers
47
+ this._onDocumentLoaded = this._handleDocumentLoaded.bind(this)
48
+ this._onMouseUp = this._handleMouseUp.bind(this)
49
+ this._onMouseDown = this._handleMouseDown.bind(this)
50
+ this._onMouseMove = this._handleMouseMove.bind(this)
51
+ this._onTouchStart = this._handleTouchStart.bind(this)
52
+ this._onTouchMove = this._handleTouchMove.bind(this)
53
+ this._onTouchEnd = this._handleTouchEnd.bind(this)
54
+ this._onKeyDown = this._handleKeyDown.bind(this)
55
+
56
+ // External store sync handlers (e.g. when highlights_panel clears all)
57
+ this._onExternalClear = this._handleExternalClear.bind(this)
58
+ this._onExternalRemove = this._handleExternalRemove.bind(this)
59
+ this._onPagesRerendered = this._renderAllHighlights.bind(this)
60
+
61
+ this.element.addEventListener(
62
+ "highlite:document-loaded",
63
+ this._onDocumentLoaded
64
+ )
65
+ this.element.addEventListener(
66
+ "highlite:highlights-cleared",
67
+ this._onExternalClear
68
+ )
69
+ this.element.addEventListener(
70
+ "highlite:highlight-removed",
71
+ this._onExternalRemove
72
+ )
73
+ this.element.addEventListener(
74
+ "highlite:pages-rerendered",
75
+ this._onPagesRerendered
76
+ )
77
+ this.element.addEventListener("mouseup", this._onMouseUp)
78
+ this.element.addEventListener("mousedown", this._onMouseDown)
79
+ this.element.addEventListener("mousemove", this._onMouseMove)
80
+ this.element.addEventListener("touchstart", this._onTouchStart, {
81
+ passive: false,
82
+ })
83
+ this.element.addEventListener("touchmove", this._onTouchMove, {
84
+ passive: false,
85
+ })
86
+ this.element.addEventListener("touchend", this._onTouchEnd)
87
+ document.addEventListener("keydown", this._onKeyDown)
88
+ }
89
+
90
+ disconnect() {
91
+ this.element.removeEventListener(
92
+ "highlite:document-loaded",
93
+ this._onDocumentLoaded
94
+ )
95
+ this.element.removeEventListener(
96
+ "highlite:highlights-cleared",
97
+ this._onExternalClear
98
+ )
99
+ this.element.removeEventListener(
100
+ "highlite:highlight-removed",
101
+ this._onExternalRemove
102
+ )
103
+ this.element.removeEventListener(
104
+ "highlite:pages-rerendered",
105
+ this._onPagesRerendered
106
+ )
107
+ this.element.removeEventListener("mouseup", this._onMouseUp)
108
+ this.element.removeEventListener("mousedown", this._onMouseDown)
109
+ this.element.removeEventListener("mousemove", this._onMouseMove)
110
+ this.element.removeEventListener("touchstart", this._onTouchStart)
111
+ this.element.removeEventListener("touchmove", this._onTouchMove)
112
+ this.element.removeEventListener("touchend", this._onTouchEnd)
113
+ document.removeEventListener("keydown", this._onKeyDown)
114
+
115
+ this._dismissPopup()
116
+ this._dismissNoteDialog()
117
+ this._cleanupDrawPreview()
118
+ }
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Actions
122
+ // ---------------------------------------------------------------------------
123
+
124
+ /**
125
+ * Set the active highlight color.
126
+ * @param {Event} event - Event with params.color
127
+ */
128
+ setColor(event) {
129
+ const color = event.params?.color || event.detail?.color
130
+ if (color) this.activeColorValue = color
131
+ }
132
+
133
+ /**
134
+ * Set the active tool: "select", "text", or "area".
135
+ * @param {Event} event - Event with params.tool
136
+ */
137
+ setTool(event) {
138
+ const tool = event.params?.tool || event.detail?.tool
139
+ if (tool) {
140
+ this.activeToolValue = tool
141
+ this._updateCursor()
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Clear highlights for the currently visible page.
147
+ */
148
+ clearPage() {
149
+ const page = this._getCurrentPage()
150
+ if (page) {
151
+ this.store.clear(page)
152
+ this._renderPageHighlights(page)
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Clear all highlights for this document.
158
+ */
159
+ clearAll() {
160
+ this.store.clear()
161
+ this._renderAllHighlights()
162
+ }
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // Document loaded handler
166
+ // ---------------------------------------------------------------------------
167
+
168
+ _handleDocumentLoaded() {
169
+ // Render existing highlights on all pages
170
+ this._renderAllHighlights()
171
+ }
172
+
173
+ /**
174
+ * Handle external highlights-cleared events (e.g. from highlights_panel clearAll).
175
+ * Reloads store from localStorage and re-renders all pages.
176
+ */
177
+ _handleExternalClear() {
178
+ this.store.load()
179
+ this._renderAllHighlights()
180
+ }
181
+
182
+ /**
183
+ * Handle external highlight-removed events (e.g. from another controller).
184
+ * Reloads store from localStorage and re-renders affected page.
185
+ */
186
+ _handleExternalRemove() {
187
+ this.store.load()
188
+ this._renderAllHighlights()
189
+ }
190
+
191
+ // ---------------------------------------------------------------------------
192
+ // Text highlight — mouseup on text layer
193
+ // ---------------------------------------------------------------------------
194
+
195
+ _handleMouseUp(event) {
196
+ if (this._drawing) {
197
+ this._finishAreaDraw(event)
198
+ return
199
+ }
200
+
201
+ // Don't interfere with area tool
202
+ if (this.activeToolValue === "area") return
203
+
204
+ const selection = window.getSelection()
205
+ if (!selection || selection.isCollapsed || !selection.toString().trim()) {
206
+ return
207
+ }
208
+
209
+ const pageWrapper = this._findPageWrapper(selection.anchorNode)
210
+ if (!pageWrapper) return
211
+
212
+ const pageNum = parseInt(pageWrapper.dataset.pageNumber, 10)
213
+ const text = selection.toString().trim()
214
+ const rawRects = this._getRawSelectionRects(selection, pageWrapper)
215
+
216
+ if (rawRects.length === 0) return
217
+
218
+ const rects = this._padAndNormalizeRects(rawRects, pageWrapper)
219
+
220
+ // Store pending selection and show popup
221
+ this._pendingSelection = { pageNum, text, rects }
222
+ this._showHighlightPopup(event.clientX, event.clientY)
223
+ }
224
+
225
+ // ---------------------------------------------------------------------------
226
+ // Highlight popup + note dialog
227
+ // ---------------------------------------------------------------------------
228
+
229
+ /**
230
+ * Show a floating "Add highlight" button near the text selection.
231
+ */
232
+ _showHighlightPopup(clientX, clientY) {
233
+ this._dismissPopup()
234
+
235
+ const popup = document.createElement("button")
236
+ popup.type = "button"
237
+ popup.className = "highlite-selection-popup"
238
+ popup.textContent = "Add highlight"
239
+
240
+ document.body.appendChild(popup)
241
+
242
+ // Position near the selection end, adjusted to stay in viewport
243
+ const popupRect = popup.getBoundingClientRect()
244
+ let left = clientX - popupRect.width / 2
245
+ let top = clientY - popupRect.height - 10
246
+
247
+ // Keep within viewport
248
+ left = Math.max(8, Math.min(left, window.innerWidth - popupRect.width - 8))
249
+ if (top < 8) top = clientY + 20
250
+
251
+ popup.style.left = `${left}px`
252
+ popup.style.top = `${top}px`
253
+
254
+ popup.addEventListener("mousedown", (e) => {
255
+ // Prevent selection from being cleared
256
+ e.preventDefault()
257
+ e.stopPropagation()
258
+ })
259
+
260
+ popup.addEventListener("click", (e) => {
261
+ e.stopPropagation()
262
+ this._dismissPopup()
263
+ this._showNoteDialog()
264
+ })
265
+
266
+ this._popup = popup
267
+
268
+ // Dismiss on click-away (delayed to avoid catching the current mouseup)
269
+ requestAnimationFrame(() => {
270
+ this._popupClickAway = (e) => {
271
+ if (this._popup && !this._popup.contains(e.target)) {
272
+ this._dismissPopup()
273
+ this._pendingSelection = null
274
+ }
275
+ }
276
+ document.addEventListener("mousedown", this._popupClickAway)
277
+ })
278
+ }
279
+
280
+ /**
281
+ * Dismiss the floating popup button.
282
+ */
283
+ _dismissPopup() {
284
+ if (this._popup) {
285
+ this._popup.remove()
286
+ this._popup = null
287
+ }
288
+ if (this._popupClickAway) {
289
+ document.removeEventListener("mousedown", this._popupClickAway)
290
+ this._popupClickAway = null
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Show a dialog with a textarea for entering a note.
296
+ */
297
+ _showNoteDialog() {
298
+ this._dismissNoteDialog()
299
+
300
+ const overlay = document.createElement("div")
301
+ overlay.className = "highlite-note-dialog-overlay"
302
+
303
+ const dialog = document.createElement("div")
304
+ dialog.className = "highlite-note-dialog"
305
+
306
+ const title = document.createElement("h3")
307
+ title.className = "highlite-note-dialog-title"
308
+ title.textContent = "Add highlight"
309
+
310
+ const preview = document.createElement("p")
311
+ preview.className = "highlite-note-dialog-preview"
312
+ const previewText = this._pendingSelection?.text || ""
313
+ preview.textContent =
314
+ previewText.length > 120
315
+ ? previewText.substring(0, 120) + "..."
316
+ : previewText
317
+
318
+ const label = document.createElement("label")
319
+ label.className = "highlite-note-dialog-label"
320
+ label.textContent = "Note (optional)"
321
+
322
+ const textarea = document.createElement("textarea")
323
+ textarea.className = "highlite-note-dialog-textarea"
324
+ textarea.placeholder = "Add a note about this highlight..."
325
+ textarea.rows = 3
326
+
327
+ const actions = document.createElement("div")
328
+ actions.className = "highlite-note-dialog-actions"
329
+
330
+ const cancelBtn = document.createElement("button")
331
+ cancelBtn.type = "button"
332
+ cancelBtn.className = "highlite-note-dialog-cancel"
333
+ cancelBtn.textContent = "Cancel"
334
+
335
+ const submitBtn = document.createElement("button")
336
+ submitBtn.type = "button"
337
+ submitBtn.className = "highlite-note-dialog-submit"
338
+ submitBtn.textContent = "Save"
339
+
340
+ actions.appendChild(cancelBtn)
341
+ actions.appendChild(submitBtn)
342
+
343
+ dialog.appendChild(title)
344
+ dialog.appendChild(preview)
345
+ dialog.appendChild(label)
346
+ dialog.appendChild(textarea)
347
+ dialog.appendChild(actions)
348
+ overlay.appendChild(dialog)
349
+ document.body.appendChild(overlay)
350
+
351
+ this._noteDialog = overlay
352
+
353
+ // Focus the textarea
354
+ requestAnimationFrame(() => textarea.focus())
355
+
356
+ // Event handlers
357
+ cancelBtn.addEventListener("click", () => {
358
+ this._dismissNoteDialog()
359
+ this._pendingSelection = null
360
+ window.getSelection()?.removeAllRanges()
361
+ })
362
+
363
+ submitBtn.addEventListener("click", () => {
364
+ this._submitHighlight(textarea.value.trim())
365
+ })
366
+
367
+ overlay.addEventListener("click", (e) => {
368
+ if (e.target === overlay) {
369
+ this._dismissNoteDialog()
370
+ this._pendingSelection = null
371
+ window.getSelection()?.removeAllRanges()
372
+ }
373
+ })
374
+
375
+ // Submit on Ctrl/Cmd+Enter
376
+ textarea.addEventListener("keydown", (e) => {
377
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
378
+ e.preventDefault()
379
+ this._submitHighlight(textarea.value.trim())
380
+ }
381
+ })
382
+ }
383
+
384
+ /**
385
+ * Dismiss the note dialog.
386
+ */
387
+ _dismissNoteDialog() {
388
+ if (this._noteDialog) {
389
+ this._noteDialog.remove()
390
+ this._noteDialog = null
391
+ }
392
+ }
393
+
394
+ /**
395
+ * Create the highlight with the pending selection data and optional note.
396
+ */
397
+ _submitHighlight(note) {
398
+ if (!this._pendingSelection) return
399
+
400
+ const { pageNum, text, rects } = this._pendingSelection
401
+
402
+ this.store.add({
403
+ page: pageNum,
404
+ type: "text",
405
+ color: this.activeColorValue,
406
+ rects,
407
+ text,
408
+ note,
409
+ })
410
+
411
+ window.getSelection()?.removeAllRanges()
412
+ this._renderPageHighlights(pageNum)
413
+
414
+ this._dismissNoteDialog()
415
+ this._pendingSelection = null
416
+ }
417
+
418
+ /**
419
+ * Handle Escape key to dismiss popup or dialog.
420
+ */
421
+ _handleKeyDown(event) {
422
+ if (event.key === "Escape") {
423
+ if (this._noteDialog) {
424
+ this._dismissNoteDialog()
425
+ this._pendingSelection = null
426
+ window.getSelection()?.removeAllRanges()
427
+ } else if (this._popup) {
428
+ this._dismissPopup()
429
+ this._pendingSelection = null
430
+ }
431
+ }
432
+ }
433
+
434
+ /**
435
+ * Get raw selection rectangles relative to the page wrapper (no padding).
436
+ * Used for text matching before padding is applied for the visual overlay.
437
+ */
438
+ _getRawSelectionRects(selection, pageWrapper) {
439
+ const rects = []
440
+ const wrapperRect = pageWrapper.getBoundingClientRect()
441
+
442
+ for (let i = 0; i < selection.rangeCount; i++) {
443
+ const range = selection.getRangeAt(i)
444
+ const clientRects = range.getClientRects()
445
+
446
+ for (const rect of clientRects) {
447
+ if (rect.width < 1 || rect.height < 1) continue
448
+
449
+ rects.push({
450
+ x: rect.left - wrapperRect.left,
451
+ y: rect.top - wrapperRect.top,
452
+ w: rect.width,
453
+ h: rect.height,
454
+ })
455
+ }
456
+ }
457
+
458
+ return this._mergeOverlappingRects(rects)
459
+ }
460
+
461
+ /**
462
+ * Add vertical padding to rects and normalize to 0-1 ratios.
463
+ */
464
+ _padAndNormalizeRects(rawRects, pageWrapper) {
465
+ const wrapperRect = pageWrapper.getBoundingClientRect()
466
+ const pageW = wrapperRect.width
467
+ const pageH = wrapperRect.height
468
+
469
+ return rawRects.map((r) => {
470
+ const padTop = r.h * 0.15
471
+ const padBottom = r.h * 0.55
472
+ const hPad = r.h * 0.1
473
+ return {
474
+ x: (r.x - hPad) / pageW,
475
+ y: (r.y - padTop) / pageH,
476
+ w: (r.w + hPad * 2) / pageW,
477
+ h: (r.h + padTop + padBottom) / pageH,
478
+ }
479
+ })
480
+ }
481
+
482
+ /**
483
+ * Get accurate text by matching raw selection rects against text layer spans.
484
+ * This avoids DOM range ordering issues where selection.toString() returns
485
+ * text from spans that are in DOM order but not visual reading order.
486
+ */
487
+ _getTextFromRects(pageWrapper, rawRects) {
488
+ const textLayer = pageWrapper.querySelector(".highlite-text-layer")
489
+ if (!textLayer) return ""
490
+
491
+ const wrapperRect = pageWrapper.getBoundingClientRect()
492
+ const spans = textLayer.querySelectorAll("span")
493
+ const matched = []
494
+
495
+ for (const span of spans) {
496
+ if (!span.textContent) continue
497
+ const sr = span.getBoundingClientRect()
498
+ const spanBox = {
499
+ x: sr.left - wrapperRect.left,
500
+ y: sr.top - wrapperRect.top,
501
+ w: sr.width,
502
+ h: sr.height,
503
+ }
504
+
505
+ if (rawRects.some((r) => this._rectsOverlap(spanBox, r))) {
506
+ matched.push(span.textContent)
507
+ }
508
+ }
509
+
510
+ return matched.join(" ").replace(/\s+/g, " ").trim()
511
+ }
512
+
513
+ /**
514
+ * Check if two rectangles overlap with sufficient vertical intersection.
515
+ * Requires at least 50% vertical overlap relative to the smaller rect
516
+ * to avoid matching spans from adjacent lines (inflated by line-height).
517
+ */
518
+ _rectsOverlap(a, b) {
519
+ const overlapX = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x)
520
+ const overlapY = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y)
521
+ if (overlapX <= 0 || overlapY <= 0) return false
522
+ const minH = Math.min(a.h, b.h)
523
+ return overlapY > minH * 0.5
524
+ }
525
+
526
+ /**
527
+ * Merge overlapping or adjacent rectangles to reduce clutter.
528
+ * @param {Array<{x: number, y: number, w: number, h: number}>} rects
529
+ * @returns {Array<{x: number, y: number, w: number, h: number}>}
530
+ */
531
+ _mergeOverlappingRects(rects) {
532
+ if (rects.length <= 1) return rects
533
+
534
+ // Sort by y then x
535
+ rects.sort((a, b) => a.y - b.y || a.x - b.x)
536
+
537
+ const merged = [rects[0]]
538
+ for (let i = 1; i < rects.length; i++) {
539
+ const last = merged[merged.length - 1]
540
+ const curr = rects[i]
541
+
542
+ // Merge if same line (similar y) and overlapping/adjacent horizontally
543
+ if (
544
+ Math.abs(curr.y - last.y) < 3 &&
545
+ curr.x <= last.x + last.w + 2
546
+ ) {
547
+ const right = Math.max(last.x + last.w, curr.x + curr.w)
548
+ last.w = right - last.x
549
+ last.h = Math.max(last.h, curr.h)
550
+ } else {
551
+ merged.push(curr)
552
+ }
553
+ }
554
+
555
+ return merged
556
+ }
557
+
558
+ // ---------------------------------------------------------------------------
559
+ // Area highlight — mousedown → drag → mouseup
560
+ // ---------------------------------------------------------------------------
561
+
562
+ _handleMouseDown(event) {
563
+ if (this.activeToolValue !== "area") return
564
+ if (event.button !== 0) return // Left click only
565
+
566
+ const pageWrapper = this._findPageWrapper(event.target)
567
+ if (!pageWrapper) return
568
+
569
+ event.preventDefault()
570
+ const wrapperRect = pageWrapper.getBoundingClientRect()
571
+
572
+ this._drawing = true
573
+ this._drawPage = pageWrapper
574
+ this._drawStart = {
575
+ x: event.clientX - wrapperRect.left,
576
+ y: event.clientY - wrapperRect.top,
577
+ }
578
+
579
+ // Create preview rectangle
580
+ this._drawPreview = document.createElement("div")
581
+ this._drawPreview.className = "highlite-area-preview"
582
+ this._drawPreview.style.cssText = `
583
+ position: absolute;
584
+ border: 2px dashed ${this.activeColorValue};
585
+ background: ${this.activeColorValue}33;
586
+ pointer-events: none;
587
+ z-index: 10;
588
+ `
589
+ const highlightLayer = pageWrapper.querySelector(
590
+ ".highlite-highlight-layer"
591
+ )
592
+ if (highlightLayer) {
593
+ highlightLayer.appendChild(this._drawPreview)
594
+ }
595
+ }
596
+
597
+ _handleMouseMove(event) {
598
+ if (!this._drawing || !this._drawPreview || !this._drawPage) return
599
+
600
+ const wrapperRect = this._drawPage.getBoundingClientRect()
601
+ const x = event.clientX - wrapperRect.left
602
+ const y = event.clientY - wrapperRect.top
603
+
604
+ const left = Math.min(this._drawStart.x, x)
605
+ const top = Math.min(this._drawStart.y, y)
606
+ const width = Math.abs(x - this._drawStart.x)
607
+ const height = Math.abs(y - this._drawStart.y)
608
+
609
+ this._drawPreview.style.left = `${left}px`
610
+ this._drawPreview.style.top = `${top}px`
611
+ this._drawPreview.style.width = `${width}px`
612
+ this._drawPreview.style.height = `${height}px`
613
+ }
614
+
615
+ _finishAreaDraw(event) {
616
+ if (!this._drawPage || !this._drawStart) {
617
+ this._cleanupDrawPreview()
618
+ return
619
+ }
620
+
621
+ const wrapperRect = this._drawPage.getBoundingClientRect()
622
+ const pageW = wrapperRect.width
623
+ const pageH = wrapperRect.height
624
+ const endX = event.clientX - wrapperRect.left
625
+ const endY = event.clientY - wrapperRect.top
626
+
627
+ const x = Math.min(this._drawStart.x, endX)
628
+ const y = Math.min(this._drawStart.y, endY)
629
+ const w = Math.abs(endX - this._drawStart.x)
630
+ const h = Math.abs(endY - this._drawStart.y)
631
+
632
+ const pageNum = parseInt(this._drawPage.dataset.pageNumber, 10)
633
+
634
+ // Only create highlight if area is large enough (> 5px in both dimensions)
635
+ if (w > 5 && h > 5) {
636
+ this.store.add({
637
+ page: pageNum,
638
+ type: "area",
639
+ color: this.activeColorValue,
640
+ rects: [{ x: x / pageW, y: y / pageH, w: w / pageW, h: h / pageH }],
641
+ text: "",
642
+ })
643
+ this._renderPageHighlights(pageNum)
644
+ }
645
+
646
+ this._cleanupDrawPreview()
647
+ }
648
+
649
+ _cleanupDrawPreview() {
650
+ if (this._drawPreview) {
651
+ this._drawPreview.remove()
652
+ this._drawPreview = null
653
+ }
654
+ this._drawing = false
655
+ this._drawStart = null
656
+ this._drawPage = null
657
+ }
658
+
659
+ // ---------------------------------------------------------------------------
660
+ // Touch event support — map to mouse equivalents
661
+ // ---------------------------------------------------------------------------
662
+
663
+ _handleTouchStart(event) {
664
+ if (this.activeToolValue !== "area") return
665
+ if (event.touches.length !== 1) return
666
+
667
+ const touch = event.touches[0]
668
+ event.preventDefault()
669
+
670
+ this._handleMouseDown({
671
+ target: touch.target,
672
+ clientX: touch.clientX,
673
+ clientY: touch.clientY,
674
+ button: 0,
675
+ preventDefault: () => {},
676
+ })
677
+ }
678
+
679
+ _handleTouchMove(event) {
680
+ if (!this._drawing) return
681
+ if (event.touches.length !== 1) return
682
+
683
+ const touch = event.touches[0]
684
+ event.preventDefault()
685
+
686
+ this._handleMouseMove({
687
+ clientX: touch.clientX,
688
+ clientY: touch.clientY,
689
+ })
690
+ }
691
+
692
+ _handleTouchEnd(event) {
693
+ if (!this._drawing) return
694
+
695
+ const touch = event.changedTouches[0]
696
+ this._finishAreaDraw({
697
+ clientX: touch.clientX,
698
+ clientY: touch.clientY,
699
+ })
700
+ }
701
+
702
+ // ---------------------------------------------------------------------------
703
+ // Highlight rendering
704
+ // ---------------------------------------------------------------------------
705
+
706
+ /**
707
+ * Render highlights on all pages.
708
+ */
709
+ _renderAllHighlights() {
710
+ const pages = this.element.querySelectorAll(".highlite-page")
711
+ for (const page of pages) {
712
+ const pageNum = parseInt(page.dataset.pageNumber, 10)
713
+ this._renderPageHighlights(pageNum)
714
+ }
715
+ }
716
+
717
+ /**
718
+ * Render highlight overlays for a specific page.
719
+ * @param {number} pageNum
720
+ */
721
+ _renderPageHighlights(pageNum) {
722
+ const page = this.element.querySelector(
723
+ `.highlite-page[data-page-number="${pageNum}"]`
724
+ )
725
+ if (!page) return
726
+
727
+ const layer = page.querySelector(".highlite-highlight-layer")
728
+ if (!layer) return
729
+
730
+ // Clear existing highlight divs (but not the draw preview)
731
+ const existing = layer.querySelectorAll(".highlite-highlight")
732
+ existing.forEach((el) => el.remove())
733
+
734
+ const highlights = this.store.getByPage(pageNum)
735
+ const pageW = page.offsetWidth
736
+ const pageH = page.offsetHeight
737
+
738
+ for (const highlight of highlights) {
739
+ for (const rect of highlight.rects) {
740
+ const div = document.createElement("div")
741
+ div.className = "highlite-highlight"
742
+ div.dataset.highlightId = highlight.id
743
+ div.style.cssText = `
744
+ position: absolute;
745
+ left: ${rect.x * pageW}px;
746
+ top: ${rect.y * pageH}px;
747
+ width: ${rect.w * pageW}px;
748
+ height: ${rect.h * pageH}px;
749
+ background-color: ${highlight.color};
750
+ opacity: 0.3;
751
+ mix-blend-mode: multiply;
752
+ cursor: pointer;
753
+ transition: opacity 0.15s;
754
+ pointer-events: auto;
755
+ `
756
+
757
+ // Click to select
758
+ div.addEventListener("click", (e) => {
759
+ e.stopPropagation()
760
+ this.store.select(highlight.id)
761
+ })
762
+
763
+ // Right-click to remove
764
+ div.addEventListener("contextmenu", (e) => {
765
+ e.preventDefault()
766
+ e.stopPropagation()
767
+ this.store.remove(highlight.id)
768
+ this._renderPageHighlights(pageNum)
769
+ })
770
+
771
+ // Hover effect
772
+ div.addEventListener("mouseenter", () => {
773
+ div.style.opacity = "0.45"
774
+ })
775
+ div.addEventListener("mouseleave", () => {
776
+ div.style.opacity = "0.3"
777
+ })
778
+
779
+ layer.appendChild(div)
780
+ }
781
+ }
782
+ }
783
+
784
+ // ---------------------------------------------------------------------------
785
+ // Helpers
786
+ // ---------------------------------------------------------------------------
787
+
788
+ /**
789
+ * Find the closest page wrapper element from a target node.
790
+ * @param {Node} node
791
+ * @returns {HTMLElement|null}
792
+ */
793
+ _findPageWrapper(node) {
794
+ if (node instanceof HTMLElement) {
795
+ return node.closest(".highlite-page")
796
+ }
797
+ // Text node — use parentElement
798
+ return node?.parentElement?.closest(".highlite-page") || null
799
+ }
800
+
801
+ /**
802
+ * Get the current page number from the viewer controller's page info.
803
+ * @returns {number|null}
804
+ */
805
+ _getCurrentPage() {
806
+ const pageInfo = this.element.querySelector(
807
+ "[data-highlite--viewer-target='pageInfo']"
808
+ )
809
+ if (!pageInfo) return 1
810
+
811
+ const match = pageInfo.textContent.match(/Page (\d+)/)
812
+ return match ? parseInt(match[1], 10) : 1
813
+ }
814
+
815
+ /**
816
+ * Update the cursor style based on the active tool.
817
+ */
818
+ _updateCursor() {
819
+ const pages = this.element.querySelectorAll(
820
+ ".highlite-highlight-layer"
821
+ )
822
+ const cursor =
823
+ this.activeToolValue === "area" ? "crosshair" : "default"
824
+
825
+ for (const page of pages) {
826
+ page.style.cursor = cursor
827
+ }
828
+ }
829
+ }