@dnax/ui 0.0.48 → 0.0.49
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/components/QBottomSheet.vue +172 -6
- package/components/QImagePicker.vue +28 -1
- package/components/QInputChat.vue +859 -0
- package/components/QInputCurrency.vue +319 -0
- package/components/QItemLabel.vue +99 -0
- package/components/QMap.vue +410 -0
- package/components/QNumericKeyboard.vue +304 -0
- package/components/QPage.vue +26 -6
- package/components/QPageContainer.vue +32 -0
- package/components/QPopupProxy.vue +378 -0
- package/components/QQrcode.vue +132 -0
- package/components/QSpreadsheet.vue +395 -69
- package/components/QTime.vue +845 -0
- package/components/QTimeline.vue +111 -0
- package/components/QTimelineEntry.vue +271 -0
- package/index.ts +11 -1
- package/lib/bottomSheet.test.ts +121 -0
- package/lib/bottomSheet.ts +115 -0
- package/lib/color.ts +13 -0
- package/lib/currency.test.ts +193 -0
- package/lib/currency.ts +301 -0
- package/lib/fixedLayout.ts +12 -11
- package/lib/icons.ts +3 -20
- package/lib/map.test.ts +365 -0
- package/lib/map.ts +438 -0
- package/lib/mapEngine.ts +158 -0
- package/lib/mapLeaflet.ts +142 -0
- package/lib/mapMaplibre.ts +106 -0
- package/lib/mapMaptiler.ts +93 -0
- package/lib/numericKeyboard.test.ts +179 -0
- package/lib/numericKeyboard.ts +166 -0
- package/lib/pagePadding.test.ts +37 -0
- package/lib/pagePadding.ts +32 -0
- package/lib/qrcode.test.ts +103 -0
- package/lib/qrcode.ts +125 -0
- package/lib/spreadsheetChanges.test.ts +230 -0
- package/lib/spreadsheetChanges.ts +331 -0
- package/package.json +5 -17
- package/shims.d.ts +19 -0
- package/styles/main.css +689 -884
- package/components/QTiptap.vue +0 -996
- package/components/internal/QTiptapTableView.vue +0 -123
- package/components/internal/QTiptapTaskItemView.vue +0 -49
- package/lib/tiptap-table.ts +0 -19
- package/lib/tiptap-task-list.ts +0 -16
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// QPopupProxy — proxy de popup façon Quasar : le contenu du slot par défaut est
|
|
3
|
+
// rendu dans un PANNEAU ancré à l'élément parent sur grand écran, et dans un
|
|
4
|
+
// DIALOGUE centré sur écran étroit — même contenu, même API dans les deux cas.
|
|
5
|
+
//
|
|
6
|
+
// <q-btn label="Actions" @click="proxy?.show()">
|
|
7
|
+
// <q-popup-proxy ref="proxy" position="bottom-end">
|
|
8
|
+
// <q-list>…</q-list>
|
|
9
|
+
// </q-popup-proxy>
|
|
10
|
+
// </q-btn>
|
|
11
|
+
//
|
|
12
|
+
// Comme QTooltip, le proxy n'a PAS de déclencheur propre : il rend une ancre
|
|
13
|
+
// invisible DANS la cible (display:none) et retrouve ainsi son élément parent —
|
|
14
|
+
// même après le Teleport du panneau vers <body> (qui couperait le lien DOM).
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Placement du panneau autour du parent : côté principal + alignement sur l'axe
|
|
18
|
+
* perpendiculaire — mêmes valeurs que QBtnActions / QBtnDropdown.
|
|
19
|
+
*/
|
|
20
|
+
export type PopupProxyPosition =
|
|
21
|
+
| "bottom-start" | "bottom-end" | "bottom"
|
|
22
|
+
| "top-start" | "top-end" | "top"
|
|
23
|
+
| "left-start" | "left-end" | "left"
|
|
24
|
+
| "right-start" | "right-end" | "right"
|
|
25
|
+
</script>
|
|
26
|
+
|
|
27
|
+
<script setup lang="ts">
|
|
28
|
+
import { computed, getCurrentInstance, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue"
|
|
29
|
+
import type { StyleValue } from "vue"
|
|
30
|
+
import { cn } from "../lib/utils"
|
|
31
|
+
import QDialog from "./QDialog.vue"
|
|
32
|
+
|
|
33
|
+
interface Props {
|
|
34
|
+
/** Ouvert (v-model) ; sinon état interne piloté par show() / hide() / toggle() */
|
|
35
|
+
modelValue?: boolean
|
|
36
|
+
/** Largeur de fenêtre (px) EN DESSOUS de laquelle le panneau devient un dialogue — défaut 599 */
|
|
37
|
+
breakpoint?: number
|
|
38
|
+
/** Placement du panneau autour du parent — défaut "bottom-end" */
|
|
39
|
+
position?: PopupProxyPosition
|
|
40
|
+
/** Distance entre le panneau et le parent, en px — défaut 4 */
|
|
41
|
+
offset?: number
|
|
42
|
+
/** Le clic extérieur / Échap ne ferment pas le popup */
|
|
43
|
+
persistent?: boolean
|
|
44
|
+
/** Classes attribuées au panneau téléporté (en plus de `q-popup-proxy__panel`) */
|
|
45
|
+
contentClass?: string
|
|
46
|
+
/** Style(s) du panneau téléporté — PRIORITAIRE sur le style calculé (placement) */
|
|
47
|
+
contentStyle?: StyleValue
|
|
48
|
+
/** Thème sombre */
|
|
49
|
+
dark?: boolean
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
53
|
+
modelValue: false,
|
|
54
|
+
breakpoint: 599,
|
|
55
|
+
position: "bottom-end",
|
|
56
|
+
offset: 4,
|
|
57
|
+
persistent: false,
|
|
58
|
+
contentClass: "",
|
|
59
|
+
contentStyle: "",
|
|
60
|
+
dark: false,
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
const emit = defineEmits<{
|
|
64
|
+
"update:modelValue": [value: boolean]
|
|
65
|
+
show: []
|
|
66
|
+
hide: []
|
|
67
|
+
}>()
|
|
68
|
+
|
|
69
|
+
// Multi-racines (ancre + Teleport ou dialogue) : Vue ne peut pas hériter les
|
|
70
|
+
// attributs tout seul. Ils sont posés sur l'ancre, seule racine toujours présente.
|
|
71
|
+
defineOptions({ inheritAttrs: false })
|
|
72
|
+
|
|
73
|
+
// — Ouverture contrôlée / interne —
|
|
74
|
+
// Détection du v-model par les PROPS RÉELLEMENT PASSÉES (vnode.props) : une prop
|
|
75
|
+
// Boolean non fournie vaut false (pas undefined) → `modelValue !== undefined`
|
|
76
|
+
// serait TOUJOURS contrôlé et le proxy ne s'ouvrirait jamais sans v-model.
|
|
77
|
+
const instance = getCurrentInstance()
|
|
78
|
+
const controlled = computed(() => {
|
|
79
|
+
const p = instance?.vnode.props
|
|
80
|
+
return !!(p && ("modelValue" in p || "onUpdate:modelValue" in p))
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
const internalOpen = ref(false)
|
|
84
|
+
const isOpen = computed(() => (controlled.value ? props.modelValue : internalOpen.value))
|
|
85
|
+
|
|
86
|
+
const setOpen = (v: boolean) => {
|
|
87
|
+
if (controlled.value) emit("update:modelValue", v)
|
|
88
|
+
else internalOpen.value = v
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// — Bascule large / étroit —
|
|
92
|
+
// Valeur identique serveur/client (1024) puis mise à jour au montage : aucun
|
|
93
|
+
// mismatch d'hydratation (le popup est de toute façon fermé au prerender).
|
|
94
|
+
const windowWidth = ref(1024)
|
|
95
|
+
const isWide = computed(() => windowWidth.value >= props.breakpoint)
|
|
96
|
+
|
|
97
|
+
// — Ancre & éléments —
|
|
98
|
+
const anchorEl = ref<HTMLElement | null>(null)
|
|
99
|
+
const parentEl = ref<HTMLElement | null>(null)
|
|
100
|
+
const panelEl = ref<HTMLElement | null>(null)
|
|
101
|
+
const dialogEl = ref<HTMLElement | null>(null)
|
|
102
|
+
|
|
103
|
+
// ═════════ Positionnement (fixed, téléporté) ═════════
|
|
104
|
+
// Placement par « point d'ancrage » (top/left fixed) + décalage par translation en
|
|
105
|
+
// % de la taille du panneau (−100% = bord opposé, −50% = centré) — même logique que
|
|
106
|
+
// QBtnActions, aucun calcul de largeur/hauteur nécessaire. Le clamp n'intervient
|
|
107
|
+
// qu'une fois le panneau mesuré : il décale alors le point d'ancrage en px pour
|
|
108
|
+
// garder le panneau dans le viewport.
|
|
109
|
+
const PAD = 8
|
|
110
|
+
const panelPos = ref({ top: 0, left: 0, transform: "none" })
|
|
111
|
+
|
|
112
|
+
const placePanel = () => {
|
|
113
|
+
const parent = parentEl.value
|
|
114
|
+
if (!parent || typeof document === "undefined") return
|
|
115
|
+
const rect = parent.getBoundingClientRect()
|
|
116
|
+
const [main, alt] = props.position.split("-") as [
|
|
117
|
+
"bottom" | "top" | "left" | "right",
|
|
118
|
+
"start" | "end" | undefined,
|
|
119
|
+
]
|
|
120
|
+
const off = props.offset
|
|
121
|
+
const midX = (rect.left + rect.right) / 2
|
|
122
|
+
const midY = (rect.top + rect.bottom) / 2
|
|
123
|
+
|
|
124
|
+
let top = 0
|
|
125
|
+
let left = 0
|
|
126
|
+
let tx = "0%"
|
|
127
|
+
let ty = "0%"
|
|
128
|
+
|
|
129
|
+
if (main === "bottom" || main === "top") {
|
|
130
|
+
// Axe horizontal : bord gauche (start), bord droit (end) ou centre
|
|
131
|
+
if (alt === "start") left = rect.left
|
|
132
|
+
else if (alt === "end") { left = rect.right; tx = "-100%" }
|
|
133
|
+
else { left = midX; tx = "-50%" }
|
|
134
|
+
if (main === "bottom") top = rect.bottom + off
|
|
135
|
+
else { top = rect.top - off; ty = "-100%" }
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
// Axe vertical : haut (start), bas (end) ou centre
|
|
139
|
+
if (alt === "start") top = rect.top
|
|
140
|
+
else if (alt === "end") { top = rect.bottom; ty = "-100%" }
|
|
141
|
+
else { top = midY; ty = "-50%" }
|
|
142
|
+
if (main === "right") left = rect.right + off
|
|
143
|
+
else { left = rect.left - off; tx = "-100%" }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Clamp viewport : seulement si le panneau est déjà rendu (taille connue)
|
|
147
|
+
const panel = panelEl.value
|
|
148
|
+
if (panel) {
|
|
149
|
+
const pw = panel.offsetWidth
|
|
150
|
+
const ph = panel.offsetHeight
|
|
151
|
+
const vw = window.innerWidth
|
|
152
|
+
const vh = window.innerHeight
|
|
153
|
+
const dx = tx === "-100%" ? -pw : tx === "-50%" ? -pw / 2 : 0
|
|
154
|
+
const dy = ty === "-100%" ? -ph : ty === "-50%" ? -ph / 2 : 0
|
|
155
|
+
const clampedX = Math.min(Math.max(left + dx, PAD), Math.max(PAD, vw - pw - PAD))
|
|
156
|
+
const clampedY = Math.min(Math.max(top + dy, PAD), Math.max(PAD, vh - ph - PAD))
|
|
157
|
+
left += clampedX - (left + dx)
|
|
158
|
+
top += clampedY - (top + dy)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
panelPos.value = {
|
|
162
|
+
top,
|
|
163
|
+
left,
|
|
164
|
+
transform: tx === "0%" && ty === "0%" ? "none" : `translate(${tx}, ${ty})`,
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const panelStyle = computed<StyleValue>(() => ({
|
|
169
|
+
top: `${panelPos.value.top}px`,
|
|
170
|
+
left: `${panelPos.value.left}px`,
|
|
171
|
+
transform: panelPos.value.transform,
|
|
172
|
+
}))
|
|
173
|
+
|
|
174
|
+
// — Suivi (resize / scroll) tant que le panneau ancré est ouvert —
|
|
175
|
+
let tracking = false
|
|
176
|
+
const startTracking = () => {
|
|
177
|
+
if (tracking || typeof window === "undefined") return
|
|
178
|
+
tracking = true
|
|
179
|
+
window.addEventListener("resize", placePanel)
|
|
180
|
+
window.addEventListener("scroll", placePanel, true)
|
|
181
|
+
}
|
|
182
|
+
const stopTracking = () => {
|
|
183
|
+
if (!tracking || typeof window === "undefined") return
|
|
184
|
+
tracking = false
|
|
185
|
+
window.removeEventListener("resize", placePanel)
|
|
186
|
+
window.removeEventListener("scroll", placePanel, true)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// — Focus : premier élément focusable à l'ouverture, restauration à la fermeture —
|
|
190
|
+
const FOCUSABLE =
|
|
191
|
+
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
192
|
+
let lastFocused: HTMLElement | null = null
|
|
193
|
+
|
|
194
|
+
const focusFirst = (container: HTMLElement | null) => {
|
|
195
|
+
if (!container) return
|
|
196
|
+
const el = container.querySelector<HTMLElement>(FOCUSABLE)
|
|
197
|
+
;(el ?? container).focus({ preventScroll: true })
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const restoreFocus = () => {
|
|
201
|
+
const el = lastFocused
|
|
202
|
+
lastFocused = null
|
|
203
|
+
if (el && typeof document !== "undefined" && document.contains(el)) el.focus({ preventScroll: true })
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// — Cycle ouverture / fermeture (commun aux deux rendus) —
|
|
207
|
+
const onOpened = async () => {
|
|
208
|
+
if (typeof document === "undefined") return
|
|
209
|
+
lastFocused = (document.activeElement as HTMLElement | null) ?? null
|
|
210
|
+
await nextTick()
|
|
211
|
+
if (isWide.value) {
|
|
212
|
+
startTracking()
|
|
213
|
+
placePanel()
|
|
214
|
+
await nextTick() // 2ᵉ passage : taille du panneau connue → clamp viewport
|
|
215
|
+
placePanel()
|
|
216
|
+
focusFirst(panelEl.value)
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
focusFirst(dialogEl.value)
|
|
220
|
+
}
|
|
221
|
+
emit("show")
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const onClosed = () => {
|
|
225
|
+
stopTracking()
|
|
226
|
+
emit("hide")
|
|
227
|
+
restoreFocus()
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
watch(isOpen, (v, old) => {
|
|
231
|
+
if (v) onOpened()
|
|
232
|
+
else if (old) onClosed()
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
// Fenêtre redimensionnée pendant l'ouverture : bascule de rendu + repositionnement
|
|
236
|
+
watch(isWide, async (wide) => {
|
|
237
|
+
if (!isOpen.value) return
|
|
238
|
+
if (wide) {
|
|
239
|
+
await nextTick()
|
|
240
|
+
startTracking()
|
|
241
|
+
placePanel()
|
|
242
|
+
await nextTick()
|
|
243
|
+
placePanel()
|
|
244
|
+
focusFirst(panelEl.value)
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
stopTracking()
|
|
248
|
+
await nextTick()
|
|
249
|
+
focusFirst(dialogEl.value)
|
|
250
|
+
}
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
const onWindowResize = () => {
|
|
254
|
+
windowWidth.value = window.innerWidth
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// — Fermeture : clic extérieur (phase de CAPTURE) et Échap — sauf `persistent` —
|
|
258
|
+
// Le rendu étroit est un QDialog : il gère lui-même backdrop / Échap (via persistent).
|
|
259
|
+
const onDocMousedown = (e: MouseEvent) => {
|
|
260
|
+
if (!isOpen.value || !isWide.value || props.persistent) return
|
|
261
|
+
const target = e.target as Node
|
|
262
|
+
if (parentEl.value?.contains(target) || panelEl.value?.contains(target)) return
|
|
263
|
+
setOpen(false)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const onDocKeydown = (e: KeyboardEvent) => {
|
|
267
|
+
if (!isOpen.value || !isWide.value || props.persistent) return
|
|
268
|
+
if (e.key !== "Escape") return
|
|
269
|
+
e.preventDefault()
|
|
270
|
+
setOpen(false)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// — Méthodes publiques —
|
|
274
|
+
const show = () => setOpen(true)
|
|
275
|
+
const hide = () => setOpen(false)
|
|
276
|
+
const toggle = () => (isOpen.value ? hide() : show())
|
|
277
|
+
|
|
278
|
+
defineExpose({ show, hide, toggle })
|
|
279
|
+
|
|
280
|
+
onMounted(() => {
|
|
281
|
+
// L'ancre (span invisible rendu DANS la cible) donne l'élément parent, même
|
|
282
|
+
// après le Teleport du panneau vers <body>.
|
|
283
|
+
parentEl.value = anchorEl.value?.parentElement ?? null
|
|
284
|
+
if (typeof window !== "undefined") {
|
|
285
|
+
windowWidth.value = window.innerWidth
|
|
286
|
+
window.addEventListener("resize", onWindowResize)
|
|
287
|
+
}
|
|
288
|
+
if (typeof document !== "undefined") {
|
|
289
|
+
// Phase de CAPTURE : sinon un `@mousedown.stop` parent (contenu de QDialog,
|
|
290
|
+
// QDataGrid…) bloque l'événement avant `document` → popup jamais fermé.
|
|
291
|
+
document.addEventListener("mousedown", onDocMousedown, true)
|
|
292
|
+
document.addEventListener("keydown", onDocKeydown)
|
|
293
|
+
}
|
|
294
|
+
if (isOpen.value) onOpened()
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
onBeforeUnmount(() => {
|
|
298
|
+
stopTracking()
|
|
299
|
+
if (typeof window !== "undefined") window.removeEventListener("resize", onWindowResize)
|
|
300
|
+
if (typeof document !== "undefined") {
|
|
301
|
+
document.removeEventListener("mousedown", onDocMousedown, true)
|
|
302
|
+
document.removeEventListener("keydown", onDocKeydown)
|
|
303
|
+
}
|
|
304
|
+
})
|
|
305
|
+
</script>
|
|
306
|
+
|
|
307
|
+
<template>
|
|
308
|
+
<!-- Ancre invisible DANS la cible : référence pour retrouver l'élément parent. -->
|
|
309
|
+
<span
|
|
310
|
+
ref="anchorEl"
|
|
311
|
+
class="q-popup-proxy__anchor"
|
|
312
|
+
aria-hidden="true"
|
|
313
|
+
style="display: none"
|
|
314
|
+
v-bind="$attrs"
|
|
315
|
+
/>
|
|
316
|
+
|
|
317
|
+
<!-- Grand écran (≥ breakpoint) : panneau ancré au parent, téléporté dans <body> -->
|
|
318
|
+
<Teleport v-if="isWide" to="body">
|
|
319
|
+
<Transition name="q-popup">
|
|
320
|
+
<div
|
|
321
|
+
v-if="isOpen"
|
|
322
|
+
ref="panelEl"
|
|
323
|
+
class="q-popup-proxy__panel"
|
|
324
|
+
:class="[dark && 'dark', contentClass]"
|
|
325
|
+
:style="[panelStyle, contentStyle]"
|
|
326
|
+
role="dialog"
|
|
327
|
+
tabindex="-1"
|
|
328
|
+
>
|
|
329
|
+
<slot />
|
|
330
|
+
</div>
|
|
331
|
+
</Transition>
|
|
332
|
+
</Teleport>
|
|
333
|
+
|
|
334
|
+
<!-- Écran étroit : même contenu rendu dans un QDialog (transition du dialog) -->
|
|
335
|
+
<QDialog
|
|
336
|
+
v-else
|
|
337
|
+
:model-value="isOpen"
|
|
338
|
+
:persistent="persistent"
|
|
339
|
+
:content-class="cn('q-popup-proxy__dialog-content', contentClass)"
|
|
340
|
+
:content-style="contentStyle"
|
|
341
|
+
@update:model-value="setOpen"
|
|
342
|
+
>
|
|
343
|
+
<div ref="dialogEl" class="q-popup-proxy__dialog">
|
|
344
|
+
<slot />
|
|
345
|
+
</div>
|
|
346
|
+
</QDialog>
|
|
347
|
+
</template>
|
|
348
|
+
|
|
349
|
+
<style scoped>
|
|
350
|
+
/* Panneau ancré (grand écran) — téléporté dans <body>, donc position: fixed et
|
|
351
|
+
couche « menus téléportés » (--q-z-menu), au-dessus des overlays modaux. */
|
|
352
|
+
.q-popup-proxy__panel {
|
|
353
|
+
position: fixed;
|
|
354
|
+
z-index: var(--q-z-menu, 3200);
|
|
355
|
+
min-width: 160px;
|
|
356
|
+
padding: 4px;
|
|
357
|
+
background-color: var(--card, #fff);
|
|
358
|
+
color: var(--foreground, #1d1d1d);
|
|
359
|
+
border: 1px solid var(--border, rgb(0 0 0 / 0.12));
|
|
360
|
+
border-radius: var(--radius, var(--q-radius, 6px));
|
|
361
|
+
box-shadow: 0 4px 16px rgb(0 0 0 / 0.15);
|
|
362
|
+
outline: none;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/* Mode sombre : le panneau est téléporté hors du conteneur du provider → on couvre
|
|
366
|
+
à la fois l'ancêtre `.dark` (html) et la classe `dark` posée par la prop. */
|
|
367
|
+
.dark .q-popup-proxy__panel,
|
|
368
|
+
.q-popup-proxy__panel.dark {
|
|
369
|
+
background-color: var(--card, #161b22);
|
|
370
|
+
color: var(--foreground, #e6edf3);
|
|
371
|
+
border-color: var(--border, rgb(255 255 255 / 0.12));
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/* Contenu du dialogue (écran étroit) — surface/ombre fournies par QDialog. */
|
|
375
|
+
.q-popup-proxy__dialog {
|
|
376
|
+
color: var(--foreground);
|
|
377
|
+
}
|
|
378
|
+
</style>
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// QQrcode — code QR : la matrice est encodée par la dépendance `qrcode` (déjà présente) et
|
|
3
|
+
// tracée par dnax.ui en **SVG** — un seul chemin, net à toutes les tailles, thémable et
|
|
4
|
+
// rendu côté serveur (l'encodage est du JS pur).
|
|
5
|
+
//
|
|
6
|
+
// <q-qrcode value="https://dnax.io" />
|
|
7
|
+
// <q-qrcode :value="url" :size="220" :margin="2" color="var(--foreground)" />
|
|
8
|
+
// <q-qrcode value="…" ecc="H" background="transparent" />
|
|
9
|
+
//
|
|
10
|
+
// Le composant expose `svg()` (SVG sérialisé : téléchargement, presse-papier) et
|
|
11
|
+
// `toDataURL()` (PNG, côté client). Le tracé vit dans `lib/qrcode.ts` (pur, testé).
|
|
12
|
+
import { computed, watchEffect } from "vue"
|
|
13
|
+
import { isDark, encodeQr, qrPath, qrSize, qrSvg, qrTotalSize, qrViewBox, type QrEcc } from "../lib/qrcode"
|
|
14
|
+
|
|
15
|
+
interface Props {
|
|
16
|
+
/** Contenu encodé (URL, texte, vCard, wifi…) — vide : rien n'est rendu */
|
|
17
|
+
value?: string
|
|
18
|
+
/** Côté du rendu : nombre (px) ou longueur CSS */
|
|
19
|
+
size?: number | string
|
|
20
|
+
/** Correction d'erreur : `L` (~7 %) → `H` (~30 %, pour un QR abîmé ou un logo au centre) */
|
|
21
|
+
ecc?: QrEcc
|
|
22
|
+
/** Zone de silence en modules (défaut 4 : c'est ce qu'attend un lecteur ; 0 pour un QR
|
|
23
|
+
* collé à son cadre — à ne faire que si le fond autour est clair et uni) */
|
|
24
|
+
margin?: number
|
|
25
|
+
/** Couleur des modules (couleur CSS quelconque, `var(--token)` comprise) */
|
|
26
|
+
color?: string
|
|
27
|
+
/** Couleur du fond — `"transparent"` laisse voir le fond du parent */
|
|
28
|
+
background?: string
|
|
29
|
+
/** Étiquette accessible du SVG (`role="img"`) */
|
|
30
|
+
label?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
34
|
+
value: "",
|
|
35
|
+
size: 160,
|
|
36
|
+
ecc: "M",
|
|
37
|
+
margin: 4,
|
|
38
|
+
color: "#000",
|
|
39
|
+
background: "#fff",
|
|
40
|
+
label: "Code QR",
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
/** Matrice de modules — `undefined` si la valeur ne tient pas dans un QR */
|
|
44
|
+
const matrix = computed(() => encodeQr(props.value, { ecc: props.ecc }))
|
|
45
|
+
|
|
46
|
+
watchEffect(() => {
|
|
47
|
+
if (props.value && !matrix.value) {
|
|
48
|
+
console.warn(
|
|
49
|
+
`[q-qrcode] valeur non encodable (trop longue pour ecc="${props.ecc}") : rien n'est rendu`,
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
const total = computed(() => (matrix.value ? qrTotalSize(matrix.value, props.margin) : 0))
|
|
55
|
+
const viewBox = computed(() => (matrix.value ? qrViewBox(matrix.value, props.margin) : "0 0 0 0"))
|
|
56
|
+
const path = computed(() => (matrix.value ? qrPath(matrix.value, props.margin) : ""))
|
|
57
|
+
const cssSize = computed(() => qrSize(props.size))
|
|
58
|
+
const transparent = computed(() => props.background === "transparent")
|
|
59
|
+
|
|
60
|
+
/** SVG sérialisé (téléchargement, presse-papier, `<img src="data:image/svg+xml…">`) */
|
|
61
|
+
const svg = (): string =>
|
|
62
|
+
matrix.value
|
|
63
|
+
? qrSvg(matrix.value, {
|
|
64
|
+
margin: props.margin,
|
|
65
|
+
color: props.color,
|
|
66
|
+
background: props.background,
|
|
67
|
+
label: props.label,
|
|
68
|
+
})
|
|
69
|
+
: ""
|
|
70
|
+
|
|
71
|
+
/** Couleur résolue en valeur calculée (le canvas ne comprend ni `var()` ni `currentColor`) */
|
|
72
|
+
const resolveColor = (value: string, fallback: string): string => {
|
|
73
|
+
if (typeof document === "undefined") return fallback
|
|
74
|
+
const probe = document.createElement("span")
|
|
75
|
+
probe.style.color = value
|
|
76
|
+
probe.style.display = "none"
|
|
77
|
+
document.body.appendChild(probe)
|
|
78
|
+
const resolved = getComputedStyle(probe).color
|
|
79
|
+
probe.remove()
|
|
80
|
+
return resolved || fallback
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** PNG (data URL) dessiné depuis la matrice — navigateur uniquement, `null` côté serveur */
|
|
84
|
+
const toDataURL = (options: { pixelSize?: number; margin?: number } = {}): string | null => {
|
|
85
|
+
const m = matrix.value
|
|
86
|
+
if (!m || typeof document === "undefined") return null
|
|
87
|
+
|
|
88
|
+
const pixel = Math.max(1, Math.floor(options.pixelSize ?? 8))
|
|
89
|
+
const margin = Math.max(0, Math.floor(options.margin ?? props.margin))
|
|
90
|
+
const side = qrTotalSize(m, margin)
|
|
91
|
+
|
|
92
|
+
const canvas = document.createElement("canvas")
|
|
93
|
+
canvas.width = canvas.height = side * pixel
|
|
94
|
+
const ctx = canvas.getContext("2d")
|
|
95
|
+
if (!ctx) return null
|
|
96
|
+
|
|
97
|
+
ctx.fillStyle = resolveColor(props.background, "transparent")
|
|
98
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
|
99
|
+
ctx.fillStyle = resolveColor(props.color, "#000")
|
|
100
|
+
|
|
101
|
+
for (let y = 0; y < m.size; y++) {
|
|
102
|
+
for (let x = 0; x < m.size; x++) {
|
|
103
|
+
if (isDark(m, x, y)) ctx.fillRect((x + margin) * pixel, (y + margin) * pixel, pixel, pixel)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return canvas.toDataURL("image/png")
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
defineExpose({
|
|
111
|
+
/** SVG complet du code courant ("" si la valeur n'est pas encodable) */
|
|
112
|
+
svg,
|
|
113
|
+
/** PNG en `data:` URL (nécessite un navigateur) */
|
|
114
|
+
toDataURL,
|
|
115
|
+
})
|
|
116
|
+
</script>
|
|
117
|
+
|
|
118
|
+
<template>
|
|
119
|
+
<svg
|
|
120
|
+
v-if="matrix"
|
|
121
|
+
class="q-qrcode"
|
|
122
|
+
:viewBox="viewBox"
|
|
123
|
+
:width="cssSize"
|
|
124
|
+
:height="cssSize"
|
|
125
|
+
role="img"
|
|
126
|
+
:aria-label="label"
|
|
127
|
+
v-bind="$attrs"
|
|
128
|
+
>
|
|
129
|
+
<rect v-if="!transparent" :width="total" :height="total" :fill="background" />
|
|
130
|
+
<path :d="path" :fill="color" shape-rendering="crispEdges" />
|
|
131
|
+
</svg>
|
|
132
|
+
</template>
|