@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
@@ -4,7 +4,7 @@
4
4
  "compatibility": {
5
5
  "nuxt": ">=3.16.0"
6
6
  },
7
- "version": "0.1.227",
7
+ "version": "0.1.229",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "0.8.4",
10
10
  "unbuild": "2.0.0"
@@ -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.228",
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": {