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