@tekkare/romulus 0.1.230 → 0.1.231
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.
package/dist/module.json
CHANGED
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
RmDocumentViewer - un document (PDF ou page HTML) affiche dans la page, avec
|
|
3
|
+
une recherche dans son texte.
|
|
4
|
+
|
|
5
|
+
Un document pose dans une `<iframe>` passe par le lecteur du navigateur : la
|
|
6
|
+
page n'a aucune prise dessus, et quand le fichier vient d'un autre domaine
|
|
7
|
+
elle ne peut meme pas lire son texte. Pour un compte rendu de deux cents
|
|
8
|
+
pages, la seule recherche possible etait Ctrl+F, que personne ne pense a
|
|
9
|
+
faire dans un tiroir, et qui ne savait rien des termes deja tapes dans la
|
|
10
|
+
page.
|
|
11
|
+
|
|
12
|
+
Le composant lit le fichier lui-meme et choisit le rendu sur son contenu,
|
|
13
|
+
pas sur son type annonce (les copies S3 sortent en `binary/octet-stream`,
|
|
14
|
+
et un proxy qui sert « du PDF » peut servir du HTML) :
|
|
15
|
+
- un PDF passe par pdf.js : pages dessinees a la demande, couche de texte ;
|
|
16
|
+
- une page HTML va dans une iframe `srcdoc` sandboxee SANS `allow-scripts`
|
|
17
|
+
(aucun script du document ne tourne) mais avec `allow-same-origin`, ce
|
|
18
|
+
qui laisse le composant parcourir son texte et y poser des surlignages
|
|
19
|
+
(API CSS Custom Highlight, le DOM du document n'est pas touche).
|
|
20
|
+
|
|
21
|
+
La barre en tete cherche dans le texte : surlignage de toutes les
|
|
22
|
+
occurrences, compteur, precedent / suivant. `query` sert de terme de
|
|
23
|
+
depart, pour ouvrir le document directement sur les passages qui l'ont fait
|
|
24
|
+
remonter dans une liste de resultats. Une liste cherche chaque terme
|
|
25
|
+
separement ; une chaine, et tout ce que l'utilisateur tape ensuite, cherche
|
|
26
|
+
une expression. La recherche ignore la casse et les accents, comme
|
|
27
|
+
`RmTextHighlight`.
|
|
28
|
+
|
|
29
|
+
Le fichier est lu par `fetch` : il doit venir de la meme origine ou d'un
|
|
30
|
+
serveur qui autorise CORS. Une app qui affiche des documents tiers les fait
|
|
31
|
+
passer par son propre proxy, et donne l'adresse d'origine en `base` pour
|
|
32
|
+
que les liens relatifs d'une page HTML pointent au bon endroit. Si le
|
|
33
|
+
fichier ne se lit pas (404, CORS, ni PDF ni HTML), le composant emet
|
|
34
|
+
`error` : a l'appelant de retomber sur une `<iframe>`.
|
|
35
|
+
|
|
36
|
+
pdf.js (~1 Mo avec son worker) n'est charge que pour un PDF.
|
|
37
|
+
-->
|
|
38
|
+
<script setup lang="ts">
|
|
39
|
+
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
|
|
40
|
+
|
|
41
|
+
const props = withDefaults(defineProps<{
|
|
42
|
+
/** Adresse du document, lue par `fetch`. */
|
|
43
|
+
src: string
|
|
44
|
+
/** Terme(s) cherche(s) a l'ouverture. Une liste cherche chaque terme. */
|
|
45
|
+
query?: string | readonly string[] | null
|
|
46
|
+
/**
|
|
47
|
+
* Adresse d'origine d'une page HTML servie par un proxy : sert de `<base>`
|
|
48
|
+
* aux liens relatifs. Sans effet sur un PDF.
|
|
49
|
+
*/
|
|
50
|
+
base?: string | null
|
|
51
|
+
lang?: 'fr' | 'en'
|
|
52
|
+
}>(), {
|
|
53
|
+
query: null,
|
|
54
|
+
base: null,
|
|
55
|
+
lang: 'fr',
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
const emit = defineEmits<{ error: [error: unknown] }>()
|
|
59
|
+
|
|
60
|
+
const WORDS = {
|
|
61
|
+
fr: {
|
|
62
|
+
search: 'Rechercher dans le document',
|
|
63
|
+
previous: 'Occurrence précédente',
|
|
64
|
+
next: 'Occurrence suivante',
|
|
65
|
+
none: 'Aucun résultat',
|
|
66
|
+
loading: 'Chargement du document…',
|
|
67
|
+
failed: 'Le document ne s’affiche pas.',
|
|
68
|
+
count: (current: number, total: number) => `${current} sur ${total}`,
|
|
69
|
+
},
|
|
70
|
+
en: {
|
|
71
|
+
search: 'Search in document',
|
|
72
|
+
previous: 'Previous match',
|
|
73
|
+
next: 'Next match',
|
|
74
|
+
none: 'No match',
|
|
75
|
+
loading: 'Loading document…',
|
|
76
|
+
failed: 'The document cannot be displayed.',
|
|
77
|
+
count: (current: number, total: number) => `${current} of ${total}`,
|
|
78
|
+
},
|
|
79
|
+
} as const
|
|
80
|
+
|
|
81
|
+
const words = computed(() => WORDS[props.lang])
|
|
82
|
+
|
|
83
|
+
const root = ref<HTMLDivElement | null>(null)
|
|
84
|
+
const scroller = ref<HTMLDivElement | null>(null)
|
|
85
|
+
const frame = ref<HTMLIFrameElement | null>(null)
|
|
86
|
+
const input = ref<HTMLInputElement | null>(null)
|
|
87
|
+
|
|
88
|
+
const status = ref<'loading' | 'ready' | 'failed'>('loading')
|
|
89
|
+
const kind = ref<'pdf' | 'html' | null>(null)
|
|
90
|
+
/** Le HTML a poser dans l'iframe, `<base>` et styles de surlignage compris. */
|
|
91
|
+
const srcdoc = ref('')
|
|
92
|
+
/** Ce qui est dans le champ. */
|
|
93
|
+
const text = ref('')
|
|
94
|
+
/**
|
|
95
|
+
* Les termes de `query` tant que le champ n'a pas ete retouche : une liste
|
|
96
|
+
* affichee « a b » ne doit pas se chercher comme l'expression « a b ».
|
|
97
|
+
*/
|
|
98
|
+
const terms = shallowRef<string[] | null>(null)
|
|
99
|
+
const matches = ref({ current: 0, total: 0 })
|
|
100
|
+
const pending = ref(false)
|
|
101
|
+
|
|
102
|
+
function seed() {
|
|
103
|
+
const q = props.query
|
|
104
|
+
if (Array.isArray(q)) {
|
|
105
|
+
const list = q.map(x => String(x).trim()).filter(Boolean)
|
|
106
|
+
terms.value = list.length > 1 ? list : null
|
|
107
|
+
text.value = list.join(' ')
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
terms.value = null
|
|
111
|
+
text.value = String(q ?? '').trim()
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Ce qui se cherche : la liste de depart, ou l'expression tapee. */
|
|
116
|
+
const searched = computed<string | string[]>(() => terms.value ?? text.value.trim())
|
|
117
|
+
const hasQuery = computed(() => (Array.isArray(searched.value) ? searched.value.length > 0 : searched.value !== ''))
|
|
118
|
+
|
|
119
|
+
function find(type: '' | 'again' = '', findPrevious = false) {
|
|
120
|
+
if (kind.value === 'pdf') findPdf(type, findPrevious)
|
|
121
|
+
else if (kind.value === 'html') findHtml(type, findPrevious)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function onInput(event: Event) {
|
|
125
|
+
text.value = (event.target as HTMLInputElement).value
|
|
126
|
+
terms.value = null
|
|
127
|
+
find()
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function step(previous: boolean) {
|
|
131
|
+
if (hasQuery.value) find('again', previous)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function onKeydown(event: KeyboardEvent) {
|
|
135
|
+
if (event.key !== 'Enter') return
|
|
136
|
+
event.preventDefault()
|
|
137
|
+
step(event.shiftKey)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Ctrl/Cmd+F dans le lecteur va au champ plutot qu'a la recherche de la page. */
|
|
141
|
+
function onRootKeydown(event: KeyboardEvent) {
|
|
142
|
+
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'f') {
|
|
143
|
+
event.preventDefault()
|
|
144
|
+
input.value?.focus()
|
|
145
|
+
input.value?.select()
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const countLabel = computed(() => {
|
|
150
|
+
if (status.value !== 'ready' || !hasQuery.value || pending.value) return ''
|
|
151
|
+
return matches.value.total
|
|
152
|
+
? words.value.count(matches.value.current, matches.value.total)
|
|
153
|
+
: words.value.none
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
/* ── Chargement ─────────────────────────────────────────────────────────── */
|
|
157
|
+
|
|
158
|
+
let abort: AbortController | null = null
|
|
159
|
+
|
|
160
|
+
/** PDF, HTML ou rien, d'apres les premiers octets. */
|
|
161
|
+
function sniff(bytes: Uint8Array): 'pdf' | 'html' | null {
|
|
162
|
+
const head = new TextDecoder('latin1').decode(bytes.subarray(0, 2048))
|
|
163
|
+
if (head.includes('%PDF-')) return 'pdf'
|
|
164
|
+
if (/<(!doctype html|html|head|body)\b/i.test(head)) return 'html'
|
|
165
|
+
return null
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function load() {
|
|
169
|
+
destroy()
|
|
170
|
+
status.value = 'loading'
|
|
171
|
+
kind.value = null
|
|
172
|
+
srcdoc.value = ''
|
|
173
|
+
matches.value = { current: 0, total: 0 }
|
|
174
|
+
seed()
|
|
175
|
+
if (!props.src) return
|
|
176
|
+
abort = new AbortController()
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
const response = await fetch(props.src, { signal: abort.signal })
|
|
180
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
181
|
+
const bytes = new Uint8Array(await response.arrayBuffer())
|
|
182
|
+
const type = sniff(bytes)
|
|
183
|
+
if (!type) throw new Error('Unsupported document')
|
|
184
|
+
kind.value = type
|
|
185
|
+
await nextTick()
|
|
186
|
+
if (type === 'pdf') await loadPdf(bytes)
|
|
187
|
+
else loadHtml(bytes)
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
// Une annulation (fermeture pendant le chargement) n'est pas une erreur.
|
|
191
|
+
const name = (error as any)?.name
|
|
192
|
+
if (name === 'AbortError' || name === 'AbortException') return
|
|
193
|
+
status.value = 'failed'
|
|
194
|
+
emit('error', error)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function destroy() {
|
|
199
|
+
abort?.abort()
|
|
200
|
+
abort = null
|
|
201
|
+
destroyPdf()
|
|
202
|
+
destroyHtml()
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
watch(() => props.src, load)
|
|
206
|
+
watch(() => props.query, () => {
|
|
207
|
+
seed()
|
|
208
|
+
find()
|
|
209
|
+
})
|
|
210
|
+
onMounted(load)
|
|
211
|
+
onBeforeUnmount(destroy)
|
|
212
|
+
|
|
213
|
+
/* ── PDF ────────────────────────────────────────────────────────────────── */
|
|
214
|
+
|
|
215
|
+
// Objets pdf.js : hors de la reactivite, Vue n'a rien a y suivre.
|
|
216
|
+
let eventBus: any = null
|
|
217
|
+
let viewer: any = null
|
|
218
|
+
let loadingTask: any = null
|
|
219
|
+
let resizeObserver: ResizeObserver | null = null
|
|
220
|
+
|
|
221
|
+
async function loadPdf(data: Uint8Array) {
|
|
222
|
+
const container = scroller.value
|
|
223
|
+
if (!container) return
|
|
224
|
+
const pdfjs: any = await import('pdfjs-dist')
|
|
225
|
+
// Le visualiseur lit la bibliotheque sur `globalThis` : elle doit etre la
|
|
226
|
+
// avant son import.
|
|
227
|
+
;(globalThis as any).pdfjsLib ??= pdfjs
|
|
228
|
+
const [{ default: workerSrc }, viewerLib] = await Promise.all([
|
|
229
|
+
// @ts-ignore import d URL, resolu par Vite cote app
|
|
230
|
+
import('pdfjs-dist/build/pdf.worker.min.mjs?url'),
|
|
231
|
+
import('pdfjs-dist/web/pdf_viewer.mjs'),
|
|
232
|
+
// @ts-ignore feuille de style, resolue par Vite cote app
|
|
233
|
+
import('pdfjs-dist/web/pdf_viewer.css'),
|
|
234
|
+
])
|
|
235
|
+
pdfjs.GlobalWorkerOptions.workerSrc ||= workerSrc
|
|
236
|
+
|
|
237
|
+
const { EventBus, PDFLinkService, PDFFindController, PDFViewer } = viewerLib as any
|
|
238
|
+
eventBus = new EventBus()
|
|
239
|
+
const linkService = new PDFLinkService({ eventBus })
|
|
240
|
+
const findController = new PDFFindController({ eventBus, linkService })
|
|
241
|
+
viewer = new PDFViewer({
|
|
242
|
+
container,
|
|
243
|
+
eventBus,
|
|
244
|
+
linkService,
|
|
245
|
+
findController,
|
|
246
|
+
removePageBorders: true,
|
|
247
|
+
})
|
|
248
|
+
linkService.setViewer(viewer)
|
|
249
|
+
|
|
250
|
+
eventBus.on('pagesinit', () => {
|
|
251
|
+
viewer.currentScaleValue = 'page-width'
|
|
252
|
+
if (hasQuery.value) find()
|
|
253
|
+
})
|
|
254
|
+
eventBus.on('updatefindcontrolstate', (e: any) => {
|
|
255
|
+
pending.value = e.state === 3
|
|
256
|
+
matches.value = e.matchesCount
|
|
257
|
+
})
|
|
258
|
+
eventBus.on('updatefindmatchescount', (e: any) => {
|
|
259
|
+
matches.value = e.matchesCount
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
loadingTask = pdfjs.getDocument({ data })
|
|
263
|
+
const pdf = await loadingTask.promise
|
|
264
|
+
viewer.setDocument(pdf)
|
|
265
|
+
linkService.setDocument(pdf)
|
|
266
|
+
status.value = 'ready'
|
|
267
|
+
|
|
268
|
+
resizeObserver = new ResizeObserver(() => {
|
|
269
|
+
if (viewer?.pagesCount) viewer.currentScaleValue = 'page-width'
|
|
270
|
+
})
|
|
271
|
+
resizeObserver.observe(container)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function findPdf(type: '' | 'again', findPrevious: boolean) {
|
|
275
|
+
eventBus?.dispatch('find', {
|
|
276
|
+
source: null,
|
|
277
|
+
type,
|
|
278
|
+
query: searched.value,
|
|
279
|
+
caseSensitive: false,
|
|
280
|
+
entireWord: false,
|
|
281
|
+
highlightAll: true,
|
|
282
|
+
findPrevious,
|
|
283
|
+
matchDiacritics: false,
|
|
284
|
+
})
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function destroyPdf() {
|
|
288
|
+
resizeObserver?.disconnect()
|
|
289
|
+
resizeObserver = null
|
|
290
|
+
loadingTask?.destroy()
|
|
291
|
+
loadingTask = null
|
|
292
|
+
viewer?.cleanup?.()
|
|
293
|
+
viewer = null
|
|
294
|
+
eventBus = null
|
|
295
|
+
const inner = scroller.value?.firstElementChild
|
|
296
|
+
if (inner) inner.innerHTML = ''
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/* ── HTML ───────────────────────────────────────────────────────────────── */
|
|
300
|
+
|
|
301
|
+
const ALL = 'rm-document-match'
|
|
302
|
+
const CURRENT = 'rm-document-match-current'
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Le texte du document, replie (minuscule, sans accent, blancs fusionnes),
|
|
306
|
+
* et pour chaque caractere replie le noeud texte et la position d'ou il vient.
|
|
307
|
+
*/
|
|
308
|
+
let index: { text: string, nodes: Text[], node: number[], offset: number[] } | null = null
|
|
309
|
+
let ranges: Range[] = []
|
|
310
|
+
let currentMatch = -1
|
|
311
|
+
|
|
312
|
+
/** Un caractere, minuscule et sans accent, en un seul caractere. */
|
|
313
|
+
function foldChar(char: string): string {
|
|
314
|
+
return char.normalize('NFD')[0]!.toLowerCase()[0]!
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function foldQuery(value: string): string {
|
|
318
|
+
let out = ''
|
|
319
|
+
for (const char of value.trim()) {
|
|
320
|
+
if (/\s/.test(char)) {
|
|
321
|
+
if (!out.endsWith(' ')) out += ' '
|
|
322
|
+
}
|
|
323
|
+
else out += foldChar(char)
|
|
324
|
+
}
|
|
325
|
+
return out
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function loadHtml(bytes: Uint8Array) {
|
|
329
|
+
const html = new TextDecoder(charsetOf(bytes)).decode(bytes)
|
|
330
|
+
// Couleurs du surlignage : l'iframe ne voit pas les jetons de la page, on
|
|
331
|
+
// lui passe leur valeur.
|
|
332
|
+
const primary = root.value
|
|
333
|
+
? getComputedStyle(root.value).getPropertyValue('--color-primary').trim() || 'currentColor'
|
|
334
|
+
: 'currentColor'
|
|
335
|
+
const base = props.base ? ` href="${props.base.replace(/"/g, '"')}"` : ''
|
|
336
|
+
// Balise ecrite en deux morceaux : la balise de style en toutes lettres
|
|
337
|
+
// ferait croire au compilateur SFC qu'un bloc de style commence ici.
|
|
338
|
+
const tag = 'sty' + 'le'
|
|
339
|
+
const head = `<base${base} target="_blank"><${tag}>`
|
|
340
|
+
+ `::highlight(${ALL}){background-color:color-mix(in srgb, ${primary} 20%, transparent)}`
|
|
341
|
+
+ `::highlight(${CURRENT}){background-color:color-mix(in srgb, ${primary} 45%, transparent)}`
|
|
342
|
+
+ `</${tag}>`
|
|
343
|
+
srcdoc.value = /<head[^>]*>/i.test(html)
|
|
344
|
+
? html.replace(/<head[^>]*>/i, match => match + head)
|
|
345
|
+
: head + html
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Le `charset` annonce par la page, UTF-8 sinon. */
|
|
349
|
+
function charsetOf(bytes: Uint8Array): string {
|
|
350
|
+
const head = new TextDecoder('latin1').decode(bytes.subarray(0, 4096))
|
|
351
|
+
const charset = head.match(/charset=["']?([\w-]+)/i)?.[1]
|
|
352
|
+
try {
|
|
353
|
+
return charset ? new TextDecoder(charset).encoding : 'utf-8'
|
|
354
|
+
}
|
|
355
|
+
catch {
|
|
356
|
+
return 'utf-8'
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function onFrameLoad() {
|
|
361
|
+
const doc = frame.value?.contentDocument
|
|
362
|
+
if (!srcdoc.value || !doc?.body) return
|
|
363
|
+
indexHtml(doc)
|
|
364
|
+
doc.addEventListener('keydown', onRootKeydown)
|
|
365
|
+
status.value = 'ready'
|
|
366
|
+
if (hasQuery.value) find()
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function indexHtml(doc: Document) {
|
|
370
|
+
const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT, {
|
|
371
|
+
acceptNode: node => (['SCRIPT', 'STYLE', 'NOSCRIPT'].includes(node.parentElement?.tagName ?? '')
|
|
372
|
+
? NodeFilter.FILTER_REJECT
|
|
373
|
+
: NodeFilter.FILTER_ACCEPT),
|
|
374
|
+
})
|
|
375
|
+
const out = { text: '', nodes: [] as Text[], node: [] as number[], offset: [] as number[] }
|
|
376
|
+
let space = true
|
|
377
|
+
for (let node = walker.nextNode() as Text | null; node; node = walker.nextNode() as Text | null) {
|
|
378
|
+
const k = out.nodes.push(node) - 1
|
|
379
|
+
const data = node.data
|
|
380
|
+
for (let i = 0; i < data.length; i++) {
|
|
381
|
+
const char = data[i]!
|
|
382
|
+
if (/\s/.test(char)) {
|
|
383
|
+
if (space) continue
|
|
384
|
+
out.text += ' '
|
|
385
|
+
space = true
|
|
386
|
+
}
|
|
387
|
+
else {
|
|
388
|
+
out.text += foldChar(char)
|
|
389
|
+
space = false
|
|
390
|
+
}
|
|
391
|
+
out.node.push(k)
|
|
392
|
+
out.offset.push(i)
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
index = out
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function findHtml(type: '' | 'again', findPrevious: boolean) {
|
|
399
|
+
const doc = frame.value?.contentDocument
|
|
400
|
+
const win = frame.value?.contentWindow
|
|
401
|
+
if (!doc || !win || !index) return
|
|
402
|
+
|
|
403
|
+
if (type === '') {
|
|
404
|
+
const queries = (Array.isArray(searched.value) ? searched.value : [searched.value])
|
|
405
|
+
.map(foldQuery)
|
|
406
|
+
.filter(Boolean)
|
|
407
|
+
const spans: [number, number][] = []
|
|
408
|
+
for (const query of queries) {
|
|
409
|
+
for (let at = index.text.indexOf(query); at !== -1; at = index.text.indexOf(query, at + 1)) {
|
|
410
|
+
spans.push([at, at + query.length])
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
// Dans l'ordre du document, sans chevauchement.
|
|
414
|
+
spans.sort((a, b) => a[0] - b[0])
|
|
415
|
+
ranges = []
|
|
416
|
+
let end = -1
|
|
417
|
+
for (const [start, stop] of spans) {
|
|
418
|
+
if (start < end) continue
|
|
419
|
+
end = stop
|
|
420
|
+
const range = doc.createRange()
|
|
421
|
+
range.setStart(index.nodes[index.node[start]!]!, index.offset[start]!)
|
|
422
|
+
range.setEnd(index.nodes[index.node[stop - 1]!]!, index.offset[stop - 1]! + 1)
|
|
423
|
+
ranges.push(range)
|
|
424
|
+
}
|
|
425
|
+
currentMatch = ranges.length ? 0 : -1
|
|
426
|
+
}
|
|
427
|
+
else if (ranges.length) {
|
|
428
|
+
currentMatch = (currentMatch + (findPrevious ? -1 : 1) + ranges.length) % ranges.length
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const highlights = (win as any).CSS?.highlights
|
|
432
|
+
const Highlight = (win as any).Highlight
|
|
433
|
+
if (highlights && Highlight) {
|
|
434
|
+
highlights.set(ALL, new Highlight(...ranges))
|
|
435
|
+
if (currentMatch >= 0) highlights.set(CURRENT, new Highlight(ranges[currentMatch]))
|
|
436
|
+
else highlights.delete(CURRENT)
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
matches.value = { current: currentMatch + 1, total: ranges.length }
|
|
440
|
+
pending.value = false
|
|
441
|
+
|
|
442
|
+
// Defilement de l'iframe seule : `scrollIntoView` ferait aussi defiler le
|
|
443
|
+
// tiroir qui la contient, et la barre sortirait de l'ecran.
|
|
444
|
+
const range = ranges[currentMatch]
|
|
445
|
+
if (range) {
|
|
446
|
+
const rect = range.getBoundingClientRect()
|
|
447
|
+
win.scrollBy({ top: rect.top - win.innerHeight / 3, left: 0 })
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function destroyHtml() {
|
|
452
|
+
frame.value?.contentDocument?.removeEventListener('keydown', onRootKeydown)
|
|
453
|
+
index = null
|
|
454
|
+
ranges = []
|
|
455
|
+
currentMatch = -1
|
|
456
|
+
}
|
|
457
|
+
</script>
|
|
458
|
+
|
|
459
|
+
<template>
|
|
460
|
+
<div ref="root" class="rm-document-viewer" @keydown="onRootKeydown">
|
|
461
|
+
<div class="rm-document-viewer__bar" role="search">
|
|
462
|
+
<label class="rm-document-viewer__field">
|
|
463
|
+
<RmIcon name="MagnifyingGlass" :size="14" color="var(--text-muted)" />
|
|
464
|
+
<input
|
|
465
|
+
ref="input"
|
|
466
|
+
class="rm-document-viewer__input"
|
|
467
|
+
type="search"
|
|
468
|
+
:value="text"
|
|
469
|
+
:placeholder="words.search"
|
|
470
|
+
:aria-label="words.search"
|
|
471
|
+
:disabled="status !== 'ready'"
|
|
472
|
+
@input="onInput"
|
|
473
|
+
@keydown="onKeydown"
|
|
474
|
+
>
|
|
475
|
+
</label>
|
|
476
|
+
<span class="rm-document-viewer__count" aria-live="polite">{{ countLabel }}</span>
|
|
477
|
+
<RmButton
|
|
478
|
+
ghost
|
|
479
|
+
icon-only
|
|
480
|
+
size="sm"
|
|
481
|
+
prefix-icon="CaretUp"
|
|
482
|
+
:aria-label="words.previous"
|
|
483
|
+
:title="words.previous"
|
|
484
|
+
:disabled="!matches.total"
|
|
485
|
+
@click="step(true)"
|
|
486
|
+
/>
|
|
487
|
+
<RmButton
|
|
488
|
+
ghost
|
|
489
|
+
icon-only
|
|
490
|
+
size="sm"
|
|
491
|
+
prefix-icon="CaretDown"
|
|
492
|
+
:aria-label="words.next"
|
|
493
|
+
:title="words.next"
|
|
494
|
+
:disabled="!matches.total"
|
|
495
|
+
@click="step(false)"
|
|
496
|
+
/>
|
|
497
|
+
</div>
|
|
498
|
+
|
|
499
|
+
<div class="rm-document-viewer__frame">
|
|
500
|
+
<!-- pdf.js exige un conteneur positionne en absolu qui defile, et un
|
|
501
|
+
enfant `.pdfViewer` ou il pose les pages. -->
|
|
502
|
+
<div v-show="kind === 'pdf'" ref="scroller" class="rm-document-viewer__scroller">
|
|
503
|
+
<div class="pdfViewer" />
|
|
504
|
+
</div>
|
|
505
|
+
<iframe
|
|
506
|
+
v-if="kind === 'html' && srcdoc"
|
|
507
|
+
ref="frame"
|
|
508
|
+
class="rm-document-viewer__page"
|
|
509
|
+
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
|
|
510
|
+
:srcdoc="srcdoc"
|
|
511
|
+
@load="onFrameLoad"
|
|
512
|
+
/>
|
|
513
|
+
<p v-if="status === 'loading'" class="rm-document-viewer__state">{{ words.loading }}</p>
|
|
514
|
+
<p v-else-if="status === 'failed'" class="rm-document-viewer__state">{{ words.failed }}</p>
|
|
515
|
+
</div>
|
|
516
|
+
</div>
|
|
517
|
+
</template>
|
|
518
|
+
|
|
519
|
+
<style scoped>
|
|
520
|
+
.rm-document-viewer{display:flex;flex-direction:column;gap:var(--space-8);height:100%;min-height:60vh}.rm-document-viewer__bar{align-items:center;display:flex;gap:var(--space-4)}.rm-document-viewer__field{align-items:center;background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-pill);display:flex;flex:1;gap:var(--space-8);height:32px;max-width:420px;min-width:0;padding:0 var(--space-12)}.rm-document-viewer__field:focus-within{border-color:var(--color-secondary);box-shadow:var(--shadow-focus)}.rm-document-viewer__input{background:none;border:none;color:var(--text-primary);flex:1;font-family:var(--font-sans);font-size:var(--font-13);min-width:0;outline:none}.rm-document-viewer__input::-moz-placeholder{color:var(--text-muted)}.rm-document-viewer__input::placeholder{color:var(--text-muted)}.rm-document-viewer__count{color:var(--text-secondary);font-size:var(--font-12);font-variant-numeric:tabular-nums;margin-left:var(--space-8);min-width:80px}.rm-document-viewer__frame{background:var(--bg-surface-2);border-radius:var(--radius-8,8px);flex:1;min-height:0;overflow:hidden;position:relative}.rm-document-viewer__scroller{inset:0;overflow:auto;position:absolute;--highlight-bg-color:color-mix(in srgb,var(--color-primary) 30%,transparent);--highlight-selected-bg-color:color-mix(in srgb,var(--color-primary) 65%,transparent)}.rm-document-viewer__scroller :deep(.pdfViewer .page){box-shadow:0 2px 8px rgba(var(--shadow-rgb),12%);margin:var(--space-12) auto}.rm-document-viewer__page{background:Canvas;border:none;color-scheme:light;height:100%;inset:0;position:absolute;width:100%}.rm-document-viewer__state{color:var(--text-secondary);display:grid;font-size:var(--font-13);inset:0;margin:0;place-items:center;position:absolute}
|
|
521
|
+
</style>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tekkare/romulus",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.231",
|
|
4
4
|
"description": "Romulus Design System - Nuxt module by Tekkare",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"html2canvas-pro": "^1.6.7",
|
|
49
49
|
"jspdf": "^4.2.0",
|
|
50
50
|
"jspdf-autotable": "^5.0.7",
|
|
51
|
-
"pdfjs-dist": "
|
|
51
|
+
"pdfjs-dist": "6.3.289",
|
|
52
52
|
"write-excel-file": "^1.4.30"
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {
|
|
@@ -1,294 +0,0 @@
|
|
|
1
|
-
<!--
|
|
2
|
-
RmPdfViewer - un PDF affiche dans la page, avec une recherche dans son texte.
|
|
3
|
-
|
|
4
|
-
Un PDF pose dans une `<iframe>` passe par le lecteur du navigateur : la page
|
|
5
|
-
n'a aucune prise dessus, et quand le fichier vient d'un autre domaine elle
|
|
6
|
-
ne peut meme pas lire son texte. Pour un compte rendu de deux cents pages,
|
|
7
|
-
la seule recherche possible etait Ctrl+F, que personne ne pense a faire dans
|
|
8
|
-
un tiroir, et qui ne savait rien des termes deja tapes dans la page.
|
|
9
|
-
|
|
10
|
-
Ici le rendu passe par pdf.js : les pages sont dessinees a la demande, une
|
|
11
|
-
couche de texte se pose dessus, et la barre en tete cherche dans ce texte
|
|
12
|
-
(surlignage de toutes les occurrences, compteur, precedent / suivant).
|
|
13
|
-
|
|
14
|
-
`query` sert de terme de depart, pour ouvrir le document directement sur
|
|
15
|
-
les passages qui l'ont fait remonter dans une liste de resultats. Une liste
|
|
16
|
-
cherche chaque terme separement ; une chaine, et tout ce que l'utilisateur
|
|
17
|
-
tape ensuite, cherche une expression. La recherche ignore la casse et les
|
|
18
|
-
accents, comme `RmTextHighlight`.
|
|
19
|
-
|
|
20
|
-
Le fichier est lu par `fetch` : il doit venir de la meme origine ou d'un
|
|
21
|
-
serveur qui autorise CORS. Une app qui affiche des documents tiers les fait
|
|
22
|
-
passer par son propre proxy. Si le fichier ne se lit pas (pas un PDF, CORS,
|
|
23
|
-
404), le composant emet `error` : a l'appelant de retomber sur une
|
|
24
|
-
`<iframe>`, qui sait au moins afficher une page HTML.
|
|
25
|
-
|
|
26
|
-
pdf.js (~1 Mo avec son worker) n'est charge qu'au montage du composant.
|
|
27
|
-
-->
|
|
28
|
-
<script setup lang="ts">
|
|
29
|
-
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
|
|
30
|
-
|
|
31
|
-
const props = withDefaults(defineProps<{
|
|
32
|
-
/** Adresse du PDF, lue par `fetch`. */
|
|
33
|
-
src: string
|
|
34
|
-
/** Terme(s) cherche(s) a l'ouverture. Une liste cherche chaque terme. */
|
|
35
|
-
query?: string | readonly string[] | null
|
|
36
|
-
lang?: 'fr' | 'en'
|
|
37
|
-
}>(), {
|
|
38
|
-
query: null,
|
|
39
|
-
lang: 'fr',
|
|
40
|
-
})
|
|
41
|
-
|
|
42
|
-
const emit = defineEmits<{ error: [error: unknown] }>()
|
|
43
|
-
|
|
44
|
-
const WORDS = {
|
|
45
|
-
fr: {
|
|
46
|
-
search: 'Rechercher dans le document',
|
|
47
|
-
previous: 'Occurrence précédente',
|
|
48
|
-
next: 'Occurrence suivante',
|
|
49
|
-
none: 'Aucun résultat',
|
|
50
|
-
loading: 'Chargement du document…',
|
|
51
|
-
failed: 'Le document ne s’affiche pas.',
|
|
52
|
-
count: (current: number, total: number) => `${current} sur ${total}`,
|
|
53
|
-
},
|
|
54
|
-
en: {
|
|
55
|
-
search: 'Search in document',
|
|
56
|
-
previous: 'Previous match',
|
|
57
|
-
next: 'Next match',
|
|
58
|
-
none: 'No match',
|
|
59
|
-
loading: 'Loading document…',
|
|
60
|
-
failed: 'The document cannot be displayed.',
|
|
61
|
-
count: (current: number, total: number) => `${current} of ${total}`,
|
|
62
|
-
},
|
|
63
|
-
} as const
|
|
64
|
-
|
|
65
|
-
const words = computed(() => WORDS[props.lang])
|
|
66
|
-
|
|
67
|
-
const scroller = ref<HTMLDivElement | null>(null)
|
|
68
|
-
const input = ref<HTMLInputElement | null>(null)
|
|
69
|
-
|
|
70
|
-
const status = ref<'loading' | 'ready' | 'failed'>('loading')
|
|
71
|
-
/** Ce qui est dans le champ. */
|
|
72
|
-
const text = ref('')
|
|
73
|
-
/**
|
|
74
|
-
* Les termes de `query` tant que le champ n'a pas ete retouche : une liste
|
|
75
|
-
* affichee « a b » ne doit pas se chercher comme l'expression « a b ».
|
|
76
|
-
*/
|
|
77
|
-
const terms = shallowRef<string[] | null>(null)
|
|
78
|
-
const matches = ref({ current: 0, total: 0 })
|
|
79
|
-
const pending = ref(false)
|
|
80
|
-
|
|
81
|
-
// Objets pdf.js : hors de la reactivite, Vue n'a rien a y suivre.
|
|
82
|
-
let eventBus: any = null
|
|
83
|
-
let viewer: any = null
|
|
84
|
-
let loadingTask: any = null
|
|
85
|
-
let resizeObserver: ResizeObserver | null = null
|
|
86
|
-
|
|
87
|
-
function seed() {
|
|
88
|
-
const q = props.query
|
|
89
|
-
if (Array.isArray(q)) {
|
|
90
|
-
const list = q.map(x => String(x).trim()).filter(Boolean)
|
|
91
|
-
terms.value = list.length > 1 ? list : null
|
|
92
|
-
text.value = list.join(' ')
|
|
93
|
-
}
|
|
94
|
-
else {
|
|
95
|
-
terms.value = null
|
|
96
|
-
text.value = String(q ?? '').trim()
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/** Ce que pdf.js cherche : la liste de depart, ou l'expression tapee. */
|
|
101
|
-
const searched = computed<string | string[]>(() => terms.value ?? text.value.trim())
|
|
102
|
-
const hasQuery = computed(() => (Array.isArray(searched.value) ? searched.value.length > 0 : searched.value !== ''))
|
|
103
|
-
|
|
104
|
-
function find(type: '' | 'again' = '', findPrevious = false) {
|
|
105
|
-
if (!eventBus) return
|
|
106
|
-
eventBus.dispatch('find', {
|
|
107
|
-
source: null,
|
|
108
|
-
type,
|
|
109
|
-
query: searched.value,
|
|
110
|
-
caseSensitive: false,
|
|
111
|
-
entireWord: false,
|
|
112
|
-
highlightAll: true,
|
|
113
|
-
findPrevious,
|
|
114
|
-
matchDiacritics: false,
|
|
115
|
-
})
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
function onInput(event: Event) {
|
|
119
|
-
text.value = (event.target as HTMLInputElement).value
|
|
120
|
-
terms.value = null
|
|
121
|
-
find()
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
function step(previous: boolean) {
|
|
125
|
-
if (hasQuery.value) find('again', previous)
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function onKeydown(event: KeyboardEvent) {
|
|
129
|
-
if (event.key !== 'Enter') return
|
|
130
|
-
event.preventDefault()
|
|
131
|
-
step(event.shiftKey)
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/** Ctrl/Cmd+F dans le lecteur va au champ plutot qu'a la recherche de la page. */
|
|
135
|
-
function onRootKeydown(event: KeyboardEvent) {
|
|
136
|
-
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'f') {
|
|
137
|
-
event.preventDefault()
|
|
138
|
-
input.value?.focus()
|
|
139
|
-
input.value?.select()
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
const countLabel = computed(() => {
|
|
144
|
-
if (status.value !== 'ready' || !hasQuery.value || pending.value) return ''
|
|
145
|
-
return matches.value.total
|
|
146
|
-
? words.value.count(matches.value.current, matches.value.total)
|
|
147
|
-
: words.value.none
|
|
148
|
-
})
|
|
149
|
-
|
|
150
|
-
async function load() {
|
|
151
|
-
destroy()
|
|
152
|
-
status.value = 'loading'
|
|
153
|
-
matches.value = { current: 0, total: 0 }
|
|
154
|
-
seed()
|
|
155
|
-
await nextTick()
|
|
156
|
-
const container = scroller.value
|
|
157
|
-
if (!container || !props.src) return
|
|
158
|
-
|
|
159
|
-
try {
|
|
160
|
-
const pdfjs: any = await import('pdfjs-dist')
|
|
161
|
-
// Le visualiseur lit la bibliotheque sur `globalThis` : elle doit etre la
|
|
162
|
-
// avant son import.
|
|
163
|
-
;(globalThis as any).pdfjsLib ??= pdfjs
|
|
164
|
-
const [{ default: workerSrc }, viewerLib] = await Promise.all([
|
|
165
|
-
// @ts-ignore import d URL, resolu par Vite cote app
|
|
166
|
-
import('pdfjs-dist/build/pdf.worker.min.mjs?url'),
|
|
167
|
-
import('pdfjs-dist/web/pdf_viewer.mjs'),
|
|
168
|
-
// @ts-ignore feuille de style, resolue par Vite cote app
|
|
169
|
-
import('pdfjs-dist/web/pdf_viewer.css'),
|
|
170
|
-
])
|
|
171
|
-
pdfjs.GlobalWorkerOptions.workerSrc ||= workerSrc
|
|
172
|
-
|
|
173
|
-
const { EventBus, PDFLinkService, PDFFindController, PDFViewer } = viewerLib as any
|
|
174
|
-
eventBus = new EventBus()
|
|
175
|
-
const linkService = new PDFLinkService({ eventBus })
|
|
176
|
-
const findController = new PDFFindController({ eventBus, linkService })
|
|
177
|
-
viewer = new PDFViewer({
|
|
178
|
-
container,
|
|
179
|
-
eventBus,
|
|
180
|
-
linkService,
|
|
181
|
-
findController,
|
|
182
|
-
removePageBorders: true,
|
|
183
|
-
})
|
|
184
|
-
linkService.setViewer(viewer)
|
|
185
|
-
|
|
186
|
-
eventBus.on('pagesinit', () => {
|
|
187
|
-
viewer.currentScaleValue = 'page-width'
|
|
188
|
-
if (hasQuery.value) find()
|
|
189
|
-
})
|
|
190
|
-
eventBus.on('updatefindcontrolstate', (e: any) => {
|
|
191
|
-
pending.value = e.state === 3
|
|
192
|
-
matches.value = e.matchesCount
|
|
193
|
-
})
|
|
194
|
-
eventBus.on('updatefindmatchescount', (e: any) => {
|
|
195
|
-
matches.value = e.matchesCount
|
|
196
|
-
})
|
|
197
|
-
|
|
198
|
-
loadingTask = pdfjs.getDocument({ url: props.src })
|
|
199
|
-
const pdf = await loadingTask.promise
|
|
200
|
-
viewer.setDocument(pdf)
|
|
201
|
-
linkService.setDocument(pdf)
|
|
202
|
-
status.value = 'ready'
|
|
203
|
-
|
|
204
|
-
resizeObserver = new ResizeObserver(() => {
|
|
205
|
-
if (viewer?.pagesCount) viewer.currentScaleValue = 'page-width'
|
|
206
|
-
})
|
|
207
|
-
resizeObserver.observe(container)
|
|
208
|
-
}
|
|
209
|
-
catch (error) {
|
|
210
|
-
// Une annulation (fermeture pendant le chargement) n'est pas une erreur.
|
|
211
|
-
if ((error as any)?.name === 'AbortException') return
|
|
212
|
-
status.value = 'failed'
|
|
213
|
-
emit('error', error)
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
function destroy() {
|
|
218
|
-
resizeObserver?.disconnect()
|
|
219
|
-
resizeObserver = null
|
|
220
|
-
loadingTask?.destroy()
|
|
221
|
-
loadingTask = null
|
|
222
|
-
viewer?.cleanup?.()
|
|
223
|
-
viewer = null
|
|
224
|
-
eventBus = null
|
|
225
|
-
if (scroller.value) {
|
|
226
|
-
const inner = scroller.value.firstElementChild
|
|
227
|
-
if (inner) inner.innerHTML = ''
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
watch(() => props.src, load)
|
|
232
|
-
watch(() => props.query, () => {
|
|
233
|
-
seed()
|
|
234
|
-
find()
|
|
235
|
-
})
|
|
236
|
-
onMounted(load)
|
|
237
|
-
onBeforeUnmount(destroy)
|
|
238
|
-
</script>
|
|
239
|
-
|
|
240
|
-
<template>
|
|
241
|
-
<div class="rm-pdf-viewer" @keydown="onRootKeydown">
|
|
242
|
-
<div class="rm-pdf-viewer__bar" role="search">
|
|
243
|
-
<label class="rm-pdf-viewer__field">
|
|
244
|
-
<RmIcon name="MagnifyingGlass" :size="14" color="var(--text-muted)" />
|
|
245
|
-
<input
|
|
246
|
-
ref="input"
|
|
247
|
-
class="rm-pdf-viewer__input"
|
|
248
|
-
type="search"
|
|
249
|
-
:value="text"
|
|
250
|
-
:placeholder="words.search"
|
|
251
|
-
:aria-label="words.search"
|
|
252
|
-
:disabled="status !== 'ready'"
|
|
253
|
-
@input="onInput"
|
|
254
|
-
@keydown="onKeydown"
|
|
255
|
-
>
|
|
256
|
-
</label>
|
|
257
|
-
<span class="rm-pdf-viewer__count" aria-live="polite">{{ countLabel }}</span>
|
|
258
|
-
<RmButton
|
|
259
|
-
ghost
|
|
260
|
-
icon-only
|
|
261
|
-
size="sm"
|
|
262
|
-
prefix-icon="CaretUp"
|
|
263
|
-
:aria-label="words.previous"
|
|
264
|
-
:title="words.previous"
|
|
265
|
-
:disabled="!matches.total"
|
|
266
|
-
@click="step(true)"
|
|
267
|
-
/>
|
|
268
|
-
<RmButton
|
|
269
|
-
ghost
|
|
270
|
-
icon-only
|
|
271
|
-
size="sm"
|
|
272
|
-
prefix-icon="CaretDown"
|
|
273
|
-
:aria-label="words.next"
|
|
274
|
-
:title="words.next"
|
|
275
|
-
:disabled="!matches.total"
|
|
276
|
-
@click="step(false)"
|
|
277
|
-
/>
|
|
278
|
-
</div>
|
|
279
|
-
|
|
280
|
-
<div class="rm-pdf-viewer__frame">
|
|
281
|
-
<!-- pdf.js exige un conteneur positionne en absolu qui defile, et un
|
|
282
|
-
enfant `.pdfViewer` ou il pose les pages. -->
|
|
283
|
-
<div ref="scroller" class="rm-pdf-viewer__scroller">
|
|
284
|
-
<div class="pdfViewer" />
|
|
285
|
-
</div>
|
|
286
|
-
<p v-if="status === 'loading'" class="rm-pdf-viewer__state">{{ words.loading }}</p>
|
|
287
|
-
<p v-else-if="status === 'failed'" class="rm-pdf-viewer__state">{{ words.failed }}</p>
|
|
288
|
-
</div>
|
|
289
|
-
</div>
|
|
290
|
-
</template>
|
|
291
|
-
|
|
292
|
-
<style scoped>
|
|
293
|
-
.rm-pdf-viewer{display:flex;flex-direction:column;gap:var(--space-8);height:100%;min-height:60vh}.rm-pdf-viewer__bar{align-items:center;display:flex;gap:var(--space-4)}.rm-pdf-viewer__field{align-items:center;background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-pill);display:flex;flex:1;gap:var(--space-8);height:32px;max-width:420px;min-width:0;padding:0 var(--space-12)}.rm-pdf-viewer__field:focus-within{border-color:var(--color-secondary);box-shadow:var(--shadow-focus)}.rm-pdf-viewer__input{background:none;border:none;color:var(--text-primary);flex:1;font-family:var(--font-sans);font-size:var(--font-13);min-width:0;outline:none}.rm-pdf-viewer__input::-moz-placeholder{color:var(--text-muted)}.rm-pdf-viewer__input::placeholder{color:var(--text-muted)}.rm-pdf-viewer__count{color:var(--text-secondary);font-size:var(--font-12);font-variant-numeric:tabular-nums;margin-left:var(--space-8);min-width:80px}.rm-pdf-viewer__frame{background:var(--bg-surface-2);border-radius:var(--radius-8,8px);flex:1;min-height:0;position:relative}.rm-pdf-viewer__scroller{inset:0;overflow:auto;position:absolute;--highlight-bg-color:color-mix(in srgb,var(--color-primary) 30%,transparent);--highlight-selected-bg-color:color-mix(in srgb,var(--color-primary) 65%,transparent)}.rm-pdf-viewer__scroller :deep(.pdfViewer .page){box-shadow:0 2px 8px rgba(var(--shadow-rgb),12%);margin:var(--space-12) auto}.rm-pdf-viewer__state{color:var(--text-secondary);display:grid;font-size:var(--font-13);inset:0;margin:0;place-items:center;position:absolute}
|
|
294
|
-
</style>
|