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,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PdfRenderer — PDF.js wrapper that abstracts all PDF.js interactions.
|
|
3
|
+
*
|
|
4
|
+
* Loads a PDF document from a URL and provides methods for rendering pages,
|
|
5
|
+
* text layers, thumbnails, and extracting metadata.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* const renderer = new PdfRenderer("/documents/sample.pdf")
|
|
9
|
+
* const { pageCount, title } = await renderer.load()
|
|
10
|
+
* await renderer.renderPage(1, canvasElement, 1.5)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const PDFJS_WORKER_CDN =
|
|
14
|
+
"https://cdn.jsdelivr.net/npm/pdfjs-dist@5.4.624/build/pdf.worker.min.mjs"
|
|
15
|
+
|
|
16
|
+
export class PdfRenderer {
|
|
17
|
+
/**
|
|
18
|
+
* @param {string} url - URL of the PDF to load
|
|
19
|
+
* @param {Object} [options={}]
|
|
20
|
+
* @param {string} [options.workerSrc] - Custom PDF.js worker URL
|
|
21
|
+
*/
|
|
22
|
+
constructor(url, options = {}) {
|
|
23
|
+
this.url = url
|
|
24
|
+
this.options = options
|
|
25
|
+
this.pdfjs = null
|
|
26
|
+
this.document = null
|
|
27
|
+
this._pages = new Map()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Load the PDF document. Must be called before any other method.
|
|
32
|
+
* @returns {Promise<{pageCount: number, title: string|null}>}
|
|
33
|
+
*/
|
|
34
|
+
async load() {
|
|
35
|
+
this.pdfjs = await import("pdfjs-dist")
|
|
36
|
+
this.pdfjs.GlobalWorkerOptions.workerSrc =
|
|
37
|
+
this.options.workerSrc || PDFJS_WORKER_CDN
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
this.document = await this.pdfjs.getDocument({ url: this.url }).promise
|
|
41
|
+
} catch (error) {
|
|
42
|
+
throw new Error(`Failed to load PDF from ${this.url}: ${error.message}`)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const metadata = await this.document.getMetadata().catch(() => null)
|
|
46
|
+
const title = metadata?.info?.Title || null
|
|
47
|
+
|
|
48
|
+
return { pageCount: this.document.numPages, title }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Get a cached page proxy.
|
|
53
|
+
* @param {number} pageNum - 1-based page number
|
|
54
|
+
* @returns {Promise<PDFPageProxy>}
|
|
55
|
+
*/
|
|
56
|
+
async _getPage(pageNum) {
|
|
57
|
+
if (!this.document) throw new Error("PDF not loaded. Call load() first.")
|
|
58
|
+
if (pageNum < 1 || pageNum > this.document.numPages) {
|
|
59
|
+
throw new RangeError(
|
|
60
|
+
`Page ${pageNum} out of range (1-${this.document.numPages})`
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!this._pages.has(pageNum)) {
|
|
65
|
+
this._pages.set(pageNum, await this.document.getPage(pageNum))
|
|
66
|
+
}
|
|
67
|
+
return this._pages.get(pageNum)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Get the viewport for a page at a given scale.
|
|
72
|
+
* @param {number} pageNum - 1-based page number
|
|
73
|
+
* @param {number} scale
|
|
74
|
+
* @returns {Promise<PDFPageViewport>}
|
|
75
|
+
*/
|
|
76
|
+
async getViewport(pageNum, scale) {
|
|
77
|
+
const page = await this._getPage(pageNum)
|
|
78
|
+
return page.getViewport({ scale })
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Render a page onto a canvas element.
|
|
83
|
+
* @param {number} pageNum - 1-based page number
|
|
84
|
+
* @param {HTMLCanvasElement} canvas
|
|
85
|
+
* @param {number} scale
|
|
86
|
+
* @returns {Promise<void>}
|
|
87
|
+
*/
|
|
88
|
+
async renderPage(pageNum, canvas, scale) {
|
|
89
|
+
const page = await this._getPage(pageNum)
|
|
90
|
+
const viewport = page.getViewport({ scale })
|
|
91
|
+
const context = canvas.getContext("2d")
|
|
92
|
+
|
|
93
|
+
const dpr = window.devicePixelRatio || 1
|
|
94
|
+
canvas.width = Math.floor(viewport.width * dpr)
|
|
95
|
+
canvas.height = Math.floor(viewport.height * dpr)
|
|
96
|
+
canvas.style.width = `${Math.floor(viewport.width)}px`
|
|
97
|
+
canvas.style.height = `${Math.floor(viewport.height)}px`
|
|
98
|
+
|
|
99
|
+
context.setTransform(dpr, 0, 0, dpr, 0, 0)
|
|
100
|
+
|
|
101
|
+
await page.render({ canvasContext: context, viewport }).promise
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Render the selectable text layer for a page.
|
|
106
|
+
* Creates positioned span elements that overlay the canvas for text selection.
|
|
107
|
+
* @param {number} pageNum - 1-based page number
|
|
108
|
+
* @param {HTMLElement} container - Element to render text spans into
|
|
109
|
+
* @param {PDFPageViewport} viewport
|
|
110
|
+
* @returns {Promise<void>}
|
|
111
|
+
*/
|
|
112
|
+
async renderTextLayer(pageNum, container, viewport) {
|
|
113
|
+
const page = await this._getPage(pageNum)
|
|
114
|
+
const textContent = await page.getTextContent()
|
|
115
|
+
|
|
116
|
+
// Clear existing text layer content
|
|
117
|
+
container.innerHTML = ""
|
|
118
|
+
|
|
119
|
+
// Use PDF.js TextLayer API
|
|
120
|
+
const textLayer = new this.pdfjs.TextLayer({
|
|
121
|
+
textContentSource: textContent,
|
|
122
|
+
container,
|
|
123
|
+
viewport,
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
await textLayer.render()
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Render a small thumbnail of a page.
|
|
131
|
+
* @param {number} pageNum - 1-based page number
|
|
132
|
+
* @param {HTMLCanvasElement} canvas
|
|
133
|
+
* @param {number} [scale=0.2]
|
|
134
|
+
* @returns {Promise<void>}
|
|
135
|
+
*/
|
|
136
|
+
async renderThumbnail(pageNum, canvas, scale = 0.2) {
|
|
137
|
+
await this.renderPage(pageNum, canvas, scale)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Extract the outline (table of contents) from the PDF.
|
|
142
|
+
* @returns {Promise<Array<{title: string, dest: *, items: Array}>>}
|
|
143
|
+
*/
|
|
144
|
+
async getOutline() {
|
|
145
|
+
if (!this.document) throw new Error("PDF not loaded. Call load() first.")
|
|
146
|
+
|
|
147
|
+
const outline = await this.document.getOutline()
|
|
148
|
+
if (!outline) return []
|
|
149
|
+
|
|
150
|
+
return this._normalizeOutline(outline)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Recursively normalize outline items, resolving page numbers from destinations.
|
|
155
|
+
* @param {Array} items - Raw outline items from PDF.js
|
|
156
|
+
* @returns {Promise<Array<{title: string, pageNum: number|null, items: Array}>>}
|
|
157
|
+
*/
|
|
158
|
+
async _normalizeOutline(items) {
|
|
159
|
+
const results = []
|
|
160
|
+
|
|
161
|
+
for (const item of items) {
|
|
162
|
+
let pageNum = null
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
if (item.dest) {
|
|
166
|
+
const dest =
|
|
167
|
+
typeof item.dest === "string"
|
|
168
|
+
? await this.document.getDestination(item.dest)
|
|
169
|
+
: item.dest
|
|
170
|
+
|
|
171
|
+
if (dest && dest[0]) {
|
|
172
|
+
const pageIndex = await this.document.getPageIndex(dest[0])
|
|
173
|
+
pageNum = pageIndex + 1 // Convert 0-based to 1-based
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
} catch {
|
|
177
|
+
// Destination resolution can fail for malformed PDFs; skip silently
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const children = item.items?.length
|
|
181
|
+
? await this._normalizeOutline(item.items)
|
|
182
|
+
: []
|
|
183
|
+
|
|
184
|
+
results.push({ title: item.title, pageNum, items: children })
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return results
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Get plain text content for a page.
|
|
192
|
+
* @param {number} pageNum - 1-based page number
|
|
193
|
+
* @returns {Promise<string>}
|
|
194
|
+
*/
|
|
195
|
+
async getPageText(pageNum) {
|
|
196
|
+
const page = await this._getPage(pageNum)
|
|
197
|
+
const textContent = await page.getTextContent()
|
|
198
|
+
|
|
199
|
+
return textContent.items.map((item) => item.str).join(" ")
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Clean up resources. Call when the viewer is torn down.
|
|
204
|
+
*/
|
|
205
|
+
destroy() {
|
|
206
|
+
if (this.document) {
|
|
207
|
+
this.document.destroy()
|
|
208
|
+
this.document = null
|
|
209
|
+
}
|
|
210
|
+
this._pages.clear()
|
|
211
|
+
}
|
|
212
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
<%# Floating annotation toolbar — positioned on the right edge of the PDF viewer %>
|
|
2
|
+
|
|
3
|
+
<div class="absolute right-4 top-1/2 -translate-y-1/2 z-20 flex flex-col gap-1.5 p-1.5 bg-gray-800/90 backdrop-blur rounded-xl shadow-lg">
|
|
4
|
+
<%# Select / cursor tool %>
|
|
5
|
+
<button type="button"
|
|
6
|
+
data-action="highlite--highlight#setTool"
|
|
7
|
+
data-highlite--highlight-tool-param="select"
|
|
8
|
+
data-highlite--highlight-target="toolButton"
|
|
9
|
+
class="p-2 rounded-lg text-white bg-gray-700 hover:bg-gray-600 ring-2 ring-blue-500 transition-all"
|
|
10
|
+
title="Select">
|
|
11
|
+
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
|
12
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M15.042 21.672 13.684 16.6m0 0-2.51 2.225.569-9.47 5.227 7.917-3.286-.672Zm-7.518-.267A8.25 8.25 0 1 1 20.25 10.5M8.288 14.212A5.25 5.25 0 1 1 17.25 10.5" />
|
|
13
|
+
</svg>
|
|
14
|
+
</button>
|
|
15
|
+
|
|
16
|
+
<%# Text highlight tool %>
|
|
17
|
+
<button type="button"
|
|
18
|
+
data-action="highlite--highlight#setTool"
|
|
19
|
+
data-highlite--highlight-tool-param="text"
|
|
20
|
+
data-highlite--highlight-target="toolButton"
|
|
21
|
+
class="p-2 rounded-lg text-gray-400 hover:text-white hover:bg-gray-600 transition-all"
|
|
22
|
+
title="Text highlight">
|
|
23
|
+
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
|
24
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" />
|
|
25
|
+
</svg>
|
|
26
|
+
</button>
|
|
27
|
+
|
|
28
|
+
<%# Area highlight tool %>
|
|
29
|
+
<button type="button"
|
|
30
|
+
data-action="highlite--highlight#setTool"
|
|
31
|
+
data-highlite--highlight-tool-param="area"
|
|
32
|
+
data-highlite--highlight-target="toolButton"
|
|
33
|
+
class="p-2 rounded-lg text-gray-400 hover:text-white hover:bg-gray-600 transition-all"
|
|
34
|
+
title="Area highlight">
|
|
35
|
+
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
|
36
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 7.5A2.25 2.25 0 0 1 7.5 5.25h9a2.25 2.25 0 0 1 2.25 2.25v9a2.25 2.25 0 0 1-2.25 2.25h-9a2.25 2.25 0 0 1-2.25-2.25v-9Z" />
|
|
37
|
+
</svg>
|
|
38
|
+
</button>
|
|
39
|
+
|
|
40
|
+
<%# Divider %>
|
|
41
|
+
<div class="border-t border-gray-600 mx-1"></div>
|
|
42
|
+
|
|
43
|
+
<%# Color buttons %>
|
|
44
|
+
<button type="button"
|
|
45
|
+
data-action="highlite--highlight#setColor"
|
|
46
|
+
data-highlite--highlight-color-param="rgba(255, 226, 143, 0.5)"
|
|
47
|
+
data-highlite--highlight-target="colorButton"
|
|
48
|
+
class="w-8 h-8 mx-auto rounded-full ring-2 ring-blue-500 transition-all"
|
|
49
|
+
style="background-color: rgba(255, 226, 143, 0.8)"
|
|
50
|
+
title="Yellow">
|
|
51
|
+
</button>
|
|
52
|
+
|
|
53
|
+
<button type="button"
|
|
54
|
+
data-action="highlite--highlight#setColor"
|
|
55
|
+
data-highlite--highlight-color-param="rgba(166, 227, 161, 0.5)"
|
|
56
|
+
data-highlite--highlight-target="colorButton"
|
|
57
|
+
class="w-8 h-8 mx-auto rounded-full ring-2 ring-transparent hover:ring-gray-500 transition-all"
|
|
58
|
+
style="background-color: rgba(166, 227, 161, 0.8)"
|
|
59
|
+
title="Green">
|
|
60
|
+
</button>
|
|
61
|
+
|
|
62
|
+
<button type="button"
|
|
63
|
+
data-action="highlite--highlight#setColor"
|
|
64
|
+
data-highlite--highlight-color-param="rgba(137, 180, 250, 0.5)"
|
|
65
|
+
data-highlite--highlight-target="colorButton"
|
|
66
|
+
class="w-8 h-8 mx-auto rounded-full ring-2 ring-transparent hover:ring-gray-500 transition-all"
|
|
67
|
+
style="background-color: rgba(137, 180, 250, 0.8)"
|
|
68
|
+
title="Blue">
|
|
69
|
+
</button>
|
|
70
|
+
|
|
71
|
+
<button type="button"
|
|
72
|
+
data-action="highlite--highlight#setColor"
|
|
73
|
+
data-highlite--highlight-color-param="rgba(245, 194, 231, 0.5)"
|
|
74
|
+
data-highlite--highlight-target="colorButton"
|
|
75
|
+
class="w-8 h-8 mx-auto rounded-full ring-2 ring-transparent hover:ring-gray-500 transition-all"
|
|
76
|
+
style="background-color: rgba(245, 194, 231, 0.8)"
|
|
77
|
+
title="Pink">
|
|
78
|
+
</button>
|
|
79
|
+
</div>
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
<%# Left sidebar — Outline and Pages tabs with dark theme %>
|
|
2
|
+
|
|
3
|
+
<div class="flex flex-col h-full bg-gray-900 text-gray-300 border-r border-gray-700">
|
|
4
|
+
<%# Tab bar %>
|
|
5
|
+
<div class="flex border-b border-gray-700">
|
|
6
|
+
<button type="button"
|
|
7
|
+
data-action="highlite--sidebar#showOutline"
|
|
8
|
+
data-highlite--sidebar-target="outlineTab"
|
|
9
|
+
class="flex-1 px-4 py-3 text-base font-medium text-gray-500 border-b-2 border-transparent hover:text-gray-300 transition-colors highlite-tab-active">
|
|
10
|
+
Outline
|
|
11
|
+
</button>
|
|
12
|
+
<button type="button"
|
|
13
|
+
data-action="highlite--sidebar#showPages"
|
|
14
|
+
data-highlite--sidebar-target="pagesTab"
|
|
15
|
+
class="flex-1 px-4 py-3 text-base font-medium text-gray-500 border-b-2 border-transparent hover:text-gray-300 transition-colors">
|
|
16
|
+
Pages
|
|
17
|
+
</button>
|
|
18
|
+
</div>
|
|
19
|
+
|
|
20
|
+
<%# Outline panel %>
|
|
21
|
+
<div data-highlite--sidebar-target="outlinePanel"
|
|
22
|
+
class="flex-1 overflow-y-auto p-3">
|
|
23
|
+
<div class="text-sm text-gray-500 italic py-4 text-center">
|
|
24
|
+
Loading outline…
|
|
25
|
+
</div>
|
|
26
|
+
</div>
|
|
27
|
+
|
|
28
|
+
<%# Pages (thumbnails) panel %>
|
|
29
|
+
<div data-highlite--sidebar-target="pagesPanel"
|
|
30
|
+
class="flex-1 overflow-y-auto p-3 hidden">
|
|
31
|
+
<div data-highlite--sidebar-target="thumbnailsContainer"
|
|
32
|
+
class="grid grid-cols-1 gap-2">
|
|
33
|
+
<div class="text-sm text-gray-500 italic py-4 text-center">
|
|
34
|
+
Loading pages…
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
|
|
39
|
+
<%# Page counter at bottom %>
|
|
40
|
+
<div data-highlite--sidebar-target="pageCounter"
|
|
41
|
+
class="border-t border-gray-700 px-3 py-2 text-base text-gray-500 text-center">
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
<%# Default right sidebar — Highlights panel (dark theme) %>
|
|
2
|
+
<%# This partial can be overridden by the host app %>
|
|
3
|
+
|
|
4
|
+
<div class="flex flex-col h-full bg-gray-900 text-gray-300 border-l border-gray-700">
|
|
5
|
+
<%# Header %>
|
|
6
|
+
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-700">
|
|
7
|
+
<div class="flex items-center gap-2">
|
|
8
|
+
<svg class="h-4 w-4 text-gray-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
|
9
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
|
|
10
|
+
</svg>
|
|
11
|
+
<span class="text-base font-medium text-white">Highlights</span>
|
|
12
|
+
</div>
|
|
13
|
+
<span data-highlite--highlights-panel-target="count"
|
|
14
|
+
class="text-sm bg-gray-700 text-gray-300 px-2 py-0.5 rounded-full">
|
|
15
|
+
0
|
|
16
|
+
</span>
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
<%# Search input %>
|
|
20
|
+
<div class="px-3 py-2 border-b border-gray-700">
|
|
21
|
+
<div class="relative">
|
|
22
|
+
<svg class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-gray-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
|
23
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
|
|
24
|
+
</svg>
|
|
25
|
+
<input type="text"
|
|
26
|
+
placeholder="Search highlights..."
|
|
27
|
+
data-highlite--highlights-panel-target="searchInput"
|
|
28
|
+
data-action="input->highlite--highlights-panel#search"
|
|
29
|
+
class="w-full bg-gray-800 border border-gray-700 rounded text-sm text-gray-300 placeholder-gray-500 pl-8 pr-3 py-1.5 focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500">
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
<%# Highlights list (populated dynamically by JS) %>
|
|
34
|
+
<div data-highlite--highlights-panel-target="list"
|
|
35
|
+
class="flex-1 overflow-y-auto px-3 py-2">
|
|
36
|
+
<div class="flex flex-col items-center justify-center h-full text-center py-8">
|
|
37
|
+
<svg class="h-8 w-8 text-gray-600 mb-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
|
38
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M9.53 16.122a3 3 0 0 0-5.78 1.128 2.25 2.25 0 0 1-2.4 2.245 4.5 4.5 0 0 0 8.4-2.245c0-.399-.078-.78-.22-1.128Zm0 0a15.998 15.998 0 0 0 3.388-1.62m-5.043-.025a15.994 15.994 0 0 1 1.622-3.395m3.42 3.42a15.995 15.995 0 0 0 4.764-4.648l3.876-5.814a1.151 1.151 0 0 0-1.597-1.597L14.146 6.32a15.996 15.996 0 0 0-4.649 4.763m3.42 3.42a6.776 6.776 0 0 0-3.42-3.42" />
|
|
39
|
+
</svg>
|
|
40
|
+
<p class="text-sm text-gray-500">No highlights yet</p>
|
|
41
|
+
<p class="text-sm text-gray-600 mt-1">Select text or use the area tool to add highlights</p>
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
<%# Clear all button %>
|
|
46
|
+
<div class="border-t border-gray-700 px-3 py-2">
|
|
47
|
+
<button type="button"
|
|
48
|
+
data-action="highlite--highlights-panel#clearAll"
|
|
49
|
+
class="w-full flex items-center justify-center gap-1.5 px-3 py-1.5 text-sm text-gray-400 hover:text-red-400 hover:bg-gray-800 rounded transition-colors">
|
|
50
|
+
<svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
|
51
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
|
|
52
|
+
</svg>
|
|
53
|
+
Clear All Highlights
|
|
54
|
+
</button>
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
<%# Top toolbar — zoom controls and page info %>
|
|
2
|
+
|
|
3
|
+
<div class="sticky top-0 z-30 bg-white border-b border-gray-200 flex items-center justify-between px-4 py-2">
|
|
4
|
+
<%# Left: Logo / title %>
|
|
5
|
+
<div class="flex items-center gap-2 text-base font-medium text-gray-700">
|
|
6
|
+
<svg class="h-5 w-5 text-gray-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
|
7
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
|
|
8
|
+
</svg>
|
|
9
|
+
<span>PDF Viewer</span>
|
|
10
|
+
</div>
|
|
11
|
+
|
|
12
|
+
<%# Center: Zoom controls %>
|
|
13
|
+
<div class="flex items-center gap-1">
|
|
14
|
+
<%# Auto (fit width) %>
|
|
15
|
+
<button type="button"
|
|
16
|
+
data-action="highlite--viewer#zoomFit"
|
|
17
|
+
class="px-2.5 py-1 text-sm font-medium text-gray-600 bg-gray-100 hover:bg-gray-200 rounded transition-colors"
|
|
18
|
+
title="Fit to width">
|
|
19
|
+
Auto
|
|
20
|
+
</button>
|
|
21
|
+
|
|
22
|
+
<%# Zoom out %>
|
|
23
|
+
<button type="button"
|
|
24
|
+
data-action="highlite--viewer#zoomOut"
|
|
25
|
+
class="p-1.5 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded transition-colors"
|
|
26
|
+
title="Zoom out (Ctrl+-)">
|
|
27
|
+
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
|
28
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14" />
|
|
29
|
+
</svg>
|
|
30
|
+
</button>
|
|
31
|
+
|
|
32
|
+
<%# Zoom level display %>
|
|
33
|
+
<span data-highlite--viewer-target="zoomLevel"
|
|
34
|
+
class="min-w-[3.5rem] text-center text-sm font-medium text-gray-600">
|
|
35
|
+
150%
|
|
36
|
+
</span>
|
|
37
|
+
|
|
38
|
+
<%# Zoom in %>
|
|
39
|
+
<button type="button"
|
|
40
|
+
data-action="highlite--viewer#zoomIn"
|
|
41
|
+
class="p-1.5 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded transition-colors"
|
|
42
|
+
title="Zoom in (Ctrl++)">
|
|
43
|
+
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
|
44
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
|
45
|
+
</svg>
|
|
46
|
+
</button>
|
|
47
|
+
|
|
48
|
+
<%# Reset to default %>
|
|
49
|
+
<button type="button"
|
|
50
|
+
data-action="highlite--viewer#zoomAuto"
|
|
51
|
+
class="p-1.5 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded transition-colors"
|
|
52
|
+
title="Reset zoom (Ctrl+0)">
|
|
53
|
+
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
|
54
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.992 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182M2.985 19.644l3.182-3.182" />
|
|
55
|
+
</svg>
|
|
56
|
+
</button>
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
<%# Right: Page info %>
|
|
60
|
+
<div class="flex items-center gap-2 text-base text-gray-500">
|
|
61
|
+
<span data-highlite--viewer-target="pageInfo">Page 1 of 1</span>
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
<%# Main 3-panel layout for the PDF viewer.
|
|
2
|
+
Locals: url, document_id, scale, show_toolbar, show_left_sidebar,
|
|
3
|
+
show_right_sidebar, right_sidebar_partial %>
|
|
4
|
+
|
|
5
|
+
<div class="flex flex-col h-screen overflow-hidden"
|
|
6
|
+
data-controller="highlite--viewer highlite--highlight"
|
|
7
|
+
data-highlite--viewer-url-value="<%= url %>"
|
|
8
|
+
data-highlite--viewer-document-id-value="<%= document_id %>"
|
|
9
|
+
data-highlite--viewer-scale-value="<%= scale %>"
|
|
10
|
+
data-highlite--highlight-document-id-value="<%= document_id %>">
|
|
11
|
+
|
|
12
|
+
<% if show_toolbar %>
|
|
13
|
+
<%= render "highlite/toolbar" %>
|
|
14
|
+
<% end %>
|
|
15
|
+
|
|
16
|
+
<div class="flex flex-1 min-h-0">
|
|
17
|
+
<%# Left Sidebar — Outline & Pages %>
|
|
18
|
+
<% if show_left_sidebar %>
|
|
19
|
+
<div class="w-[280px] flex-shrink-0" data-controller="highlite--sidebar">
|
|
20
|
+
<%= render "highlite/left_sidebar" %>
|
|
21
|
+
</div>
|
|
22
|
+
<% end %>
|
|
23
|
+
|
|
24
|
+
<%# Center — PDF Viewer %>
|
|
25
|
+
<div class="flex-1 relative bg-gray-900 overflow-hidden">
|
|
26
|
+
<%# Loading overlay %>
|
|
27
|
+
<div data-highlite--viewer-target="loader"
|
|
28
|
+
class="absolute inset-0 z-40 flex items-center justify-center bg-gray-900">
|
|
29
|
+
<div class="flex flex-col items-center gap-3 text-gray-400">
|
|
30
|
+
<svg class="animate-spin h-8 w-8" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
31
|
+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
32
|
+
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
33
|
+
</svg>
|
|
34
|
+
<span class="text-sm">Loading PDF…</span>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
<%# Scrollable pages container %>
|
|
39
|
+
<div data-highlite--viewer-target="pagesContainer"
|
|
40
|
+
class="highlite-pages-container h-full overflow-auto">
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
<%# Floating annotation toolbar %>
|
|
44
|
+
<%= render "highlite/floating_toolbar" %>
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
<%# Right Sidebar — Highlights panel (overridable) %>
|
|
48
|
+
<% if show_right_sidebar %>
|
|
49
|
+
<div class="w-[320px] flex-shrink-0"
|
|
50
|
+
data-controller="highlite--highlights-panel"
|
|
51
|
+
data-highlite--highlights-panel-document-id-value="<%= document_id %>">
|
|
52
|
+
<% if right_sidebar_partial.present? %>
|
|
53
|
+
<%= render partial: right_sidebar_partial %>
|
|
54
|
+
<% else %>
|
|
55
|
+
<%= render "highlite/right_sidebar" %>
|
|
56
|
+
<% end %>
|
|
57
|
+
</div>
|
|
58
|
+
<% end %>
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
<%# Page info (used by viewer controller) %>
|
|
62
|
+
<div data-highlite--viewer-target="pageInfo" class="hidden"></div>
|
|
63
|
+
</div>
|
data/config/importmap.rb
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
version = Highlite.configuration.pdf_js_version
|
|
4
|
+
|
|
5
|
+
pin "pdfjs-dist", to: "https://cdn.jsdelivr.net/npm/pdfjs-dist@#{version}/build/pdf.min.mjs", preload: true
|
|
6
|
+
pin "pdfjs-dist/build/pdf.worker.min.mjs",
|
|
7
|
+
to: "https://cdn.jsdelivr.net/npm/pdfjs-dist@#{version}/build/pdf.worker.min.mjs"
|
|
8
|
+
|
|
9
|
+
# Gem JS entry point and internal modules
|
|
10
|
+
pin "highlite", to: "highlite/index.js"
|
|
11
|
+
pin "highlite/lib/pdf_renderer", to: "highlite/lib/pdf_renderer.js"
|
|
12
|
+
pin "highlite/lib/highlight_store", to: "highlite/lib/highlight_store.js"
|
|
13
|
+
pin "highlite/controllers/viewer_controller", to: "highlite/controllers/viewer_controller.js"
|
|
14
|
+
pin "highlite/controllers/highlight_controller", to: "highlite/controllers/highlight_controller.js"
|
|
15
|
+
pin "highlite/controllers/sidebar_controller", to: "highlite/controllers/sidebar_controller.js"
|
|
16
|
+
pin "highlite/controllers/highlights_panel_controller",
|
|
17
|
+
to: "highlite/controllers/highlights_panel_controller.js"
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Highlite
|
|
4
|
+
module Generators
|
|
5
|
+
class InstallGenerator < Rails::Generators::Base
|
|
6
|
+
source_root File.expand_path("templates", __dir__)
|
|
7
|
+
|
|
8
|
+
desc "Install Highlite: creates initializer and optionally copies sidebar override template"
|
|
9
|
+
|
|
10
|
+
class_option :sidebar, type: :boolean, default: false,
|
|
11
|
+
desc: "Copy the right sidebar partial for customization"
|
|
12
|
+
|
|
13
|
+
def create_initializer
|
|
14
|
+
template "initializer.rb.tt", "config/initializers/highlite.rb"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def copy_sidebar_override
|
|
18
|
+
return unless options[:sidebar]
|
|
19
|
+
|
|
20
|
+
template "_right_sidebar.html.erb.tt",
|
|
21
|
+
"app/views/highlite/_right_sidebar.html.erb"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def add_importmap_pin
|
|
25
|
+
return unless File.exist?(Rails.root.join("config/importmap.rb"))
|
|
26
|
+
|
|
27
|
+
append_to_file "config/importmap.rb", <<~RUBY
|
|
28
|
+
|
|
29
|
+
# Highlite
|
|
30
|
+
pin "highlite", to: "highlite/index.js"
|
|
31
|
+
RUBY
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def show_readme
|
|
35
|
+
say ""
|
|
36
|
+
say "Highlite installed successfully!", :green
|
|
37
|
+
say ""
|
|
38
|
+
say "Usage in any view:"
|
|
39
|
+
say ' <%= highlite_viewer(url: url_for(@document.file), document_id: @document.id) %>'
|
|
40
|
+
say ""
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<%# Custom right sidebar for Highlite %>
|
|
2
|
+
<%# This partial receives: url, document_id, and options %>
|
|
3
|
+
<%# Customize below to fit your application's needs %>
|
|
4
|
+
|
|
5
|
+
<div data-controller="highlite--highlights-panel"
|
|
6
|
+
data-highlite--highlights-panel-document-id-value="<%%= document_id %>"
|
|
7
|
+
class="h-full flex flex-col">
|
|
8
|
+
<div class="p-3 border-b border-gray-200">
|
|
9
|
+
<h3 class="text-sm font-semibold text-gray-700">Highlights</h3>
|
|
10
|
+
</div>
|
|
11
|
+
|
|
12
|
+
<div data-highlite--highlights-panel-target="list"
|
|
13
|
+
class="flex-1 overflow-y-auto p-3 space-y-2">
|
|
14
|
+
<%# Highlights will be rendered here by the controller %>
|
|
15
|
+
</div>
|
|
16
|
+
</div>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Highlite.configure do |config|
|
|
4
|
+
# PDF.js version (served from jsDelivr CDN)
|
|
5
|
+
# config.pdf_js_version = "5.4.624"
|
|
6
|
+
|
|
7
|
+
# Default highlight colors (rgba strings)
|
|
8
|
+
# config.default_colors = [
|
|
9
|
+
# "rgba(255, 226, 143, 0.5)", # yellow
|
|
10
|
+
# "rgba(166, 227, 161, 0.5)", # green
|
|
11
|
+
# "rgba(137, 180, 250, 0.5)", # blue
|
|
12
|
+
# "rgba(245, 194, 231, 0.5)" # pink
|
|
13
|
+
# ]
|
|
14
|
+
|
|
15
|
+
# Enabled toolbar tools (:text for text selection, :area for rectangle drawing)
|
|
16
|
+
# config.toolbar_tools = [:text, :area]
|
|
17
|
+
|
|
18
|
+
# Override the right sidebar partial (set to a partial path string)
|
|
19
|
+
# config.right_sidebar_partial = "my_app/custom_sidebar"
|
|
20
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Highlite
|
|
4
|
+
class Configuration
|
|
5
|
+
attr_accessor :pdf_js_version, :default_colors, :toolbar_tools, :right_sidebar_partial
|
|
6
|
+
|
|
7
|
+
def initialize
|
|
8
|
+
@pdf_js_version = "5.4.624"
|
|
9
|
+
@default_colors = [
|
|
10
|
+
"rgba(255, 226, 143, 0.5)", # yellow
|
|
11
|
+
"rgba(166, 227, 161, 0.5)", # green
|
|
12
|
+
"rgba(137, 180, 250, 0.5)", # blue
|
|
13
|
+
"rgba(245, 194, 231, 0.5)" # pink
|
|
14
|
+
]
|
|
15
|
+
@toolbar_tools = %i[text area]
|
|
16
|
+
@right_sidebar_partial = nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.instance
|
|
20
|
+
@instance ||= new
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.reset!
|
|
24
|
+
@instance = new
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|