@tekkare/romulus 0.1.228 → 0.1.230
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
|
@@ -109,6 +109,24 @@ function toggle(which: RmFilterBarPanel) {
|
|
|
109
109
|
panel.value = panel.value === which ? null : which
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Un clic hors de la barre referme le panneau ouvert. Les menus des listes
|
|
114
|
+
* deroulantes, les popovers et les modales sont teleportes sous <body>, hors
|
|
115
|
+
* de l'arbre de l'app : un clic dedans appartient encore au panneau (choisir
|
|
116
|
+
* une option ne doit pas le fermer), on ne ferme donc que pour un clic tombe
|
|
117
|
+
* dans le meme conteneur racine que la barre.
|
|
118
|
+
*/
|
|
119
|
+
const root = ref<HTMLElement | null>(null)
|
|
120
|
+
|
|
121
|
+
function onDocPointerDown(event: PointerEvent) {
|
|
122
|
+
if (!panel.value || !root.value) return
|
|
123
|
+
const target = event.target as Node
|
|
124
|
+
if (root.value.contains(target)) return
|
|
125
|
+
let appRoot: HTMLElement = root.value
|
|
126
|
+
while (appRoot.parentElement && appRoot.parentElement !== document.body) appRoot = appRoot.parentElement
|
|
127
|
+
if (appRoot.contains(target)) panel.value = null
|
|
128
|
+
}
|
|
129
|
+
|
|
112
130
|
/**
|
|
113
131
|
* Le lien de recherche avancee est pose DANS le champ, a son bout : le champ
|
|
114
132
|
* doit donc lui reserver sa place, sinon le texte saisi et la croix passent
|
|
@@ -157,6 +175,7 @@ function watchHeader() {
|
|
|
157
175
|
onMounted(() => {
|
|
158
176
|
watchHeader()
|
|
159
177
|
watchAdvanced()
|
|
178
|
+
document.addEventListener('pointerdown', onDocPointerDown)
|
|
160
179
|
})
|
|
161
180
|
|
|
162
181
|
onBeforeUnmount(() => {
|
|
@@ -164,12 +183,14 @@ onBeforeUnmount(() => {
|
|
|
164
183
|
headerSize = null
|
|
165
184
|
advancedSize?.disconnect()
|
|
166
185
|
advancedSize = null
|
|
186
|
+
document.removeEventListener('pointerdown', onDocPointerDown)
|
|
167
187
|
})
|
|
168
188
|
</script>
|
|
169
189
|
|
|
170
190
|
<template>
|
|
171
191
|
<div
|
|
172
192
|
id="rm_filter_bar"
|
|
193
|
+
ref="root"
|
|
173
194
|
class="rm-fbar"
|
|
174
195
|
:class="panel ? 'rm-fbar--open' : 'rm-fbar--closed'"
|
|
175
196
|
:style="{ top: `${stickyTop}px`, '--rm-fbar-advanced-w': `${advancedWidth}px` }"
|
|
@@ -0,0 +1,294 @@
|
|
|
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>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tekkare/romulus",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.230",
|
|
4
4
|
"description": "Romulus Design System - Nuxt module by Tekkare",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -48,6 +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": "^6.3.289",
|
|
51
52
|
"write-excel-file": "^1.4.30"
|
|
52
53
|
},
|
|
53
54
|
"devDependencies": {
|