@dnax/ui 0.0.28 → 0.0.30
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/QBar.vue +42 -0
- package/components/QBoard.vue +285 -0
- package/components/QConfigProvider.vue +9 -5
- package/components/QInteract.vue +387 -0
- package/components/QSpreadsheet.vue +4662 -0
- package/components/QVideo.vue +17 -1
- package/index.ts +6 -0
- package/lib/config.ts +6 -0
- package/lib/formula.test.ts +206 -0
- package/lib/formula.ts +292 -0
- package/lib/icons.ts +17 -0
- package/lib/intersection.ts +118 -0
- package/lib/spreadsheet.test.ts +92 -0
- package/lib/spreadsheet.ts +175 -0
- package/lib/useComponentProps.ts +7 -0
- package/module.ts +2 -0
- package/package.json +3 -1
- package/scripts/generate-exports.ts +2 -0
- package/shims.d.ts +6 -0
- package/styles/main.css +1344 -0
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// QInteract — conteneur d'éléments positionnables (drag & resize), pour
|
|
3
|
+
// construire des dashboards / tableaux de bord type Power BI.
|
|
4
|
+
// Chaque élément : { id, x, y, w, h } en pixels, plus toutes les données
|
|
5
|
+
// métier que vous voulez (title, type de widget…). v-model:items reçoit les
|
|
6
|
+
// nouvelles positions après chaque interaction ; chaque drag / resize émet
|
|
7
|
+
// aussi ses événements dédiés.
|
|
8
|
+
export interface QInteractItem {
|
|
9
|
+
/** Identifiant unique de l'élément */
|
|
10
|
+
id: string | number
|
|
11
|
+
/** Position gauche (px) */
|
|
12
|
+
x: number
|
|
13
|
+
/** Position haut (px) */
|
|
14
|
+
y: number
|
|
15
|
+
/** Largeur (px) */
|
|
16
|
+
w: number
|
|
17
|
+
/** Hauteur (px) */
|
|
18
|
+
h: number
|
|
19
|
+
/** Données métier libres (title, type, …) */
|
|
20
|
+
[key: string]: any
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface QInteractLayout {
|
|
24
|
+
/** Position en pixels */
|
|
25
|
+
x: number
|
|
26
|
+
y: number
|
|
27
|
+
/** Position exprimée en colonnes / rangées pour un damier de `columns` colonnes */
|
|
28
|
+
column: number
|
|
29
|
+
row: number
|
|
30
|
+
spanColumns: number
|
|
31
|
+
spanRows: number
|
|
32
|
+
}
|
|
33
|
+
</script>
|
|
34
|
+
|
|
35
|
+
<script setup lang="ts">
|
|
36
|
+
import { computed, ref, watch } from "vue"
|
|
37
|
+
import { Icon } from "@iconify/vue"
|
|
38
|
+
import { cn } from "../lib/utils"
|
|
39
|
+
|
|
40
|
+
interface Props {
|
|
41
|
+
/** Éléments (v-model:items) : [{ id, x, y, w, h, … }] */
|
|
42
|
+
items?: QInteractItem[]
|
|
43
|
+
/** Élément sélectionné (v-model:selected) : son id */
|
|
44
|
+
selected?: string | number | null
|
|
45
|
+
/** Hauteur du conteneur (px ou CSS) */
|
|
46
|
+
height?: string | number
|
|
47
|
+
/** Largeur du conteneur (px ou CSS) */
|
|
48
|
+
width?: string | number
|
|
49
|
+
/** Autorise drag & resize (défaut true) */
|
|
50
|
+
interactive?: boolean
|
|
51
|
+
/** Lecture seule : drag & resize désactivés, poignées masquées */
|
|
52
|
+
readonly?: boolean
|
|
53
|
+
/** Uniquement draggable depuis la barre d'en-tête (sinon toute la carte) */
|
|
54
|
+
handle?: boolean
|
|
55
|
+
/** Pas de grille (snap) en px — 0 désactive */
|
|
56
|
+
snap?: number
|
|
57
|
+
/** Marge intérieure minimale (px) */
|
|
58
|
+
padding?: number
|
|
59
|
+
/** Espacement entre les éléments (px) — évite qu'ils soient collés */
|
|
60
|
+
gap?: number
|
|
61
|
+
/** Largeur minimale d'un élément (px) */
|
|
62
|
+
minWidth?: number
|
|
63
|
+
/** Hauteur minimale d'un élément (px) */
|
|
64
|
+
minHeight?: number
|
|
65
|
+
/** Affiche les lignes de la grille */
|
|
66
|
+
showGrid?: boolean
|
|
67
|
+
/** Empêche le chevauchement des éléments (drag/resize bloqués sur une zone occupée) */
|
|
68
|
+
collision?: boolean
|
|
69
|
+
/** Hauteur réduite (curseurs + barre) */
|
|
70
|
+
dense?: boolean
|
|
71
|
+
dark?: boolean
|
|
72
|
+
/** Clic sur un élément le passe au premier plan */
|
|
73
|
+
bringToFront?: boolean
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
77
|
+
items: () => [],
|
|
78
|
+
selected: null,
|
|
79
|
+
height: 420,
|
|
80
|
+
width: "100%",
|
|
81
|
+
interactive: true,
|
|
82
|
+
readonly: false,
|
|
83
|
+
handle: false,
|
|
84
|
+
snap: 8,
|
|
85
|
+
padding: 10,
|
|
86
|
+
gap: 0,
|
|
87
|
+
minWidth: 60,
|
|
88
|
+
minHeight: 40,
|
|
89
|
+
showGrid: false,
|
|
90
|
+
collision: false,
|
|
91
|
+
dense: false,
|
|
92
|
+
dark: false,
|
|
93
|
+
bringToFront: true,
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
const emit = defineEmits<{
|
|
97
|
+
"update:items": [value: QInteractItem[]]
|
|
98
|
+
"update:selected": [value: string | number | null]
|
|
99
|
+
select: [item: QInteractItem]
|
|
100
|
+
"drag-start": [item: QInteractItem]
|
|
101
|
+
"drag-move": [item: QInteractItem]
|
|
102
|
+
"drag-end": [item: QInteractItem]
|
|
103
|
+
"resize-start": [item: QInteractItem]
|
|
104
|
+
"resize-move": [item: QInteractItem]
|
|
105
|
+
"resize-end": [item: QInteractItem]
|
|
106
|
+
}>()
|
|
107
|
+
|
|
108
|
+
const local = ref<QInteractItem[]>([])
|
|
109
|
+
watch(
|
|
110
|
+
() => props.items,
|
|
111
|
+
(v) => {
|
|
112
|
+
if (v && v !== local.value) local.value = v.map((it) => ({ ...it }))
|
|
113
|
+
},
|
|
114
|
+
{ immediate: true },
|
|
115
|
+
)
|
|
116
|
+
const push = (next: QInteractItem[]) => {
|
|
117
|
+
local.value = next
|
|
118
|
+
emit("update:items", next)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const sel = ref<string | number | null>(props.selected)
|
|
122
|
+
watch(
|
|
123
|
+
() => props.selected,
|
|
124
|
+
(v) => {
|
|
125
|
+
sel.value = v
|
|
126
|
+
},
|
|
127
|
+
)
|
|
128
|
+
const select = (item: QInteractItem) => {
|
|
129
|
+
sel.value = item.id
|
|
130
|
+
emit("update:selected", item.id)
|
|
131
|
+
emit("select", item)
|
|
132
|
+
}
|
|
133
|
+
const byId = (id: string | number) => local.value.find((it) => String(it.id) === String(id))
|
|
134
|
+
const indexOf = (id: string | number) => local.value.findIndex((it) => String(it.id) === String(id))
|
|
135
|
+
|
|
136
|
+
// Met à jour un élément (fusionne, ne remplace pas l'objet pendant le drag)
|
|
137
|
+
const patchItem = (id: string | number, patch: Partial<QInteractItem>, live = false) => {
|
|
138
|
+
const i = indexOf(id)
|
|
139
|
+
if (i < 0) return
|
|
140
|
+
const merged = { ...local.value[i], ...patch } as QInteractItem
|
|
141
|
+
const next = [...local.value]
|
|
142
|
+
next[i] = merged
|
|
143
|
+
if (live) {
|
|
144
|
+
local.value = next
|
|
145
|
+
} else {
|
|
146
|
+
push(next)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const rootEl = ref<HTMLElement | null>(null)
|
|
151
|
+
const sizeStyle = computed<Record<string, string>>(() => ({
|
|
152
|
+
height: typeof props.height === "number" ? props.height + "px" : props.height,
|
|
153
|
+
width: typeof props.width === "number" ? props.width + "px" : props.width,
|
|
154
|
+
}))
|
|
155
|
+
const gridStyle = computed<Record<string, string>>(() => {
|
|
156
|
+
const s = Math.max(1, props.snap || 1)
|
|
157
|
+
return { backgroundSize: s + "px " + s + "px" }
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
// ─── Drag ───
|
|
161
|
+
const drag = ref<{ id: string | number; dx: number; dy: number; sx: number; sy: number; item: QInteractItem } | null>(null)
|
|
162
|
+
|
|
163
|
+
const isHandle = (el: EventTarget | null): boolean => {
|
|
164
|
+
const node = el as HTMLElement | null
|
|
165
|
+
if (!props.handle) return false
|
|
166
|
+
return !!node?.closest?.(".q-interact__handle")
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const canInteract = () => props.interactive && !props.readonly
|
|
170
|
+
const dragStart = (e: PointerEvent, item: QInteractItem) => {
|
|
171
|
+
if (!canInteract()) return
|
|
172
|
+
if (e.button !== 0) return
|
|
173
|
+
if (props.handle && !isHandle(e.target)) return
|
|
174
|
+
select(item)
|
|
175
|
+
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
|
176
|
+
const root = rootEl.value?.getBoundingClientRect()
|
|
177
|
+
if (!root) return
|
|
178
|
+
drag.value = {
|
|
179
|
+
id: item.id,
|
|
180
|
+
sx: item.x,
|
|
181
|
+
sy: item.y,
|
|
182
|
+
dx: e.clientX - rect.left,
|
|
183
|
+
dy: e.clientY - rect.top,
|
|
184
|
+
item,
|
|
185
|
+
}
|
|
186
|
+
emit("drag-start", item)
|
|
187
|
+
window.addEventListener("pointermove", onDragMove)
|
|
188
|
+
window.addEventListener("pointerup", onDragEnd)
|
|
189
|
+
}
|
|
190
|
+
const onDragMove = (e: PointerEvent) => {
|
|
191
|
+
const d = drag.value
|
|
192
|
+
if (!d || !rootEl.value) return
|
|
193
|
+
const root = rootEl.value.getBoundingClientRect()
|
|
194
|
+
const snap = Math.max(1, props.snap || 1)
|
|
195
|
+
const cur = byId(d.id)
|
|
196
|
+
if (!cur) return
|
|
197
|
+
const maxX = root.width - props.padding - cur.w + (props.gap || 0) / 2
|
|
198
|
+
const maxY = root.height - props.padding - cur.h + (props.gap || 0) / 2
|
|
199
|
+
const nx = Math.round(Math.max(props.padding, Math.min(maxX, e.clientX - root.left - d.dx)) / snap) * snap
|
|
200
|
+
const ny = Math.round(Math.max(props.padding, Math.min(maxY, e.clientY - root.top - d.dy)) / snap) * snap
|
|
201
|
+
// Collision : on ne pose pas l'élément sur une zone occupée
|
|
202
|
+
if (props.collision && collidesWithOthers(d.id, { x: nx, y: ny, w: cur.w, h: cur.h })) {
|
|
203
|
+
if (cur.x !== nx || cur.y !== ny) emit("drag-move", cur)
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
patchItem(d.id, { x: nx, y: ny }, true)
|
|
207
|
+
const updated = byId(d.id)
|
|
208
|
+
if (updated) emit("drag-move", updated)
|
|
209
|
+
}
|
|
210
|
+
const onDragEnd = () => {
|
|
211
|
+
window.removeEventListener("pointermove", onDragMove)
|
|
212
|
+
window.removeEventListener("pointerup", onDragEnd)
|
|
213
|
+
const d = drag.value
|
|
214
|
+
drag.value = null
|
|
215
|
+
if (!d) return
|
|
216
|
+
const cur = byId(d.id)
|
|
217
|
+
if (cur) emit("update:items", local.value.map((it) => ({ ...it })))
|
|
218
|
+
if (cur) emit("drag-end", cur)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ─── Resize (poignée bas-droite) ───
|
|
222
|
+
const rs = ref<{ id: string | number; item: QInteractItem } | null>(null)
|
|
223
|
+
const resizeStart = (e: PointerEvent, item: QInteractItem) => {
|
|
224
|
+
if (!canInteract()) return
|
|
225
|
+
e.stopPropagation()
|
|
226
|
+
select(item)
|
|
227
|
+
rs.value = { id: item.id, item }
|
|
228
|
+
emit("resize-start", item)
|
|
229
|
+
window.addEventListener("pointermove", onResizeMove)
|
|
230
|
+
window.addEventListener("pointerup", onResizeEnd)
|
|
231
|
+
}
|
|
232
|
+
const intersectsRect = (a: { x: number; y: number; w: number; h: number }, b: { x: number; y: number; w: number; h: number }) =>
|
|
233
|
+
a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y
|
|
234
|
+
const collidesWithOthers = (id: string | number, rect: { x: number; y: number; w: number; h: number }) =>
|
|
235
|
+
props.collision &&
|
|
236
|
+
local.value.some((it) => String(it.id) !== String(id) && intersectsRect(rect, it))
|
|
237
|
+
const onResizeMove = (e: PointerEvent) => {
|
|
238
|
+
const r = rs.value
|
|
239
|
+
if (!r || !rootEl.value) return
|
|
240
|
+
const root = rootEl.value.getBoundingClientRect()
|
|
241
|
+
const base = byId(r.id)
|
|
242
|
+
if (!base) return
|
|
243
|
+
const snap = Math.max(1, props.snap || 1)
|
|
244
|
+
const cur = local.value[indexOf(r.id)]!
|
|
245
|
+
const g = props.gap || 0
|
|
246
|
+
const w = Math.round(Math.min(root.width - props.padding - cur.x + g / 2, Math.max(props.minWidth, e.clientX - root.left - cur.x)) / snap) * snap
|
|
247
|
+
const h = Math.round(Math.min(root.height - props.padding - cur.y + g / 2, Math.max(props.minHeight, e.clientY - root.top - cur.y)) / snap) * snap
|
|
248
|
+
if (collidesWithOthers(r.id, { x: cur.x, y: cur.y, w, h })) {
|
|
249
|
+
if (cur.w !== w || cur.h !== h) emit("resize-move", cur)
|
|
250
|
+
return
|
|
251
|
+
}
|
|
252
|
+
patchItem(r.id, { w, h }, true)
|
|
253
|
+
const updated = byId(r.id)
|
|
254
|
+
if (updated) emit("resize-move", updated)
|
|
255
|
+
}
|
|
256
|
+
const onResizeEnd = () => {
|
|
257
|
+
window.removeEventListener("pointermove", onResizeMove)
|
|
258
|
+
window.removeEventListener("pointerup", onResizeEnd)
|
|
259
|
+
const r = rs.value
|
|
260
|
+
rs.value = null
|
|
261
|
+
if (!r) return
|
|
262
|
+
const cur = byId(r.id)
|
|
263
|
+
if (cur) emit("update:items", local.value.map((it) => ({ ...it })))
|
|
264
|
+
if (cur) emit("resize-end", cur)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ─── API ───
|
|
268
|
+
const itemStyle = (item: QInteractItem) => {
|
|
269
|
+
const g = props.gap || 0
|
|
270
|
+
return {
|
|
271
|
+
left: item.x + g / 2 + "px",
|
|
272
|
+
top: item.y + g / 2 + "px",
|
|
273
|
+
width: Math.max(0, item.w - g) + "px",
|
|
274
|
+
height: Math.max(0, item.h - g) + "px",
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
const isSelected = (item: QInteractItem) => String(sel.value) === String(item.id)
|
|
278
|
+
|
|
279
|
+
const bringToFront = (item: QInteractItem) => {
|
|
280
|
+
if (!props.bringToFront) return
|
|
281
|
+
const i = indexOf(item.id)
|
|
282
|
+
if (i < 0) return
|
|
283
|
+
const next = [...local.value]
|
|
284
|
+
const it = next.splice(i, 1)[0] as QInteractItem
|
|
285
|
+
next.push(it)
|
|
286
|
+
push(next)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const addItem = (item: QInteractItem) => {
|
|
290
|
+
push([...local.value, { ...item }])
|
|
291
|
+
select(item)
|
|
292
|
+
}
|
|
293
|
+
const removeItem = (id: string | number) => {
|
|
294
|
+
push(local.value.filter((it) => String(it.id) !== String(id)))
|
|
295
|
+
if (String(sel.value) === String(id)) sel.value = null
|
|
296
|
+
}
|
|
297
|
+
const clear = () => push([])
|
|
298
|
+
|
|
299
|
+
/** Positions réelles (racine du conteneur) en px */
|
|
300
|
+
const realPos = (item: QInteractItem) => {
|
|
301
|
+
const root = rootEl.value?.getBoundingClientRect()
|
|
302
|
+
if (!root) return { x: item.x, y: item.y }
|
|
303
|
+
return { x: root.left + item.x, y: root.top + item.y }
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Exprime chaque élément en tuiles pour un damier de `columns` colonnes :
|
|
308
|
+
* { x, y, column, row, spanColumns, spanRows } — utile pour décrire le layout
|
|
309
|
+
* d'un dashboard à reconstruire sur d'autres écrans (Power BI-like).
|
|
310
|
+
*/
|
|
311
|
+
const toGrid = (columns = 12): QInteractLayout[] => {
|
|
312
|
+
const root = rootEl.value
|
|
313
|
+
if (!root) return []
|
|
314
|
+
const colW = root.clientWidth / columns
|
|
315
|
+
const rowH = props.snap || 10
|
|
316
|
+
return local.value.map((it) => ({
|
|
317
|
+
x: it.x,
|
|
318
|
+
y: it.y,
|
|
319
|
+
column: Math.round(it.x / colW),
|
|
320
|
+
row: Math.round(it.y / rowH),
|
|
321
|
+
spanColumns: Math.max(1, Math.round(it.w / colW)),
|
|
322
|
+
spanRows: Math.max(1, Math.round(it.h / rowH)),
|
|
323
|
+
}))
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const rootClasses = computed(() =>
|
|
327
|
+
cn(
|
|
328
|
+
"q-interact",
|
|
329
|
+
props.dense && "q-interact--dense",
|
|
330
|
+
props.dark && "q-interact--dark",
|
|
331
|
+
props.showGrid && "q-interact--grid",
|
|
332
|
+
!props.interactive && "q-interact--static",
|
|
333
|
+
props.readonly && "q-interact--static",
|
|
334
|
+
),
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
defineExpose({
|
|
338
|
+
select,
|
|
339
|
+
addItem,
|
|
340
|
+
removeItem,
|
|
341
|
+
clear,
|
|
342
|
+
bringToFront,
|
|
343
|
+
getItem: byId,
|
|
344
|
+
getItems: () => local.value.map((it) => ({ ...it })),
|
|
345
|
+
realPos,
|
|
346
|
+
toGrid,
|
|
347
|
+
})
|
|
348
|
+
</script>
|
|
349
|
+
|
|
350
|
+
<template>
|
|
351
|
+
<div ref="rootEl" class="q-interact" :class="rootClasses" :style="[sizeStyle, props.showGrid ? gridStyle : undefined]">
|
|
352
|
+
<template v-for="item in local" :key="item.id">
|
|
353
|
+
<div
|
|
354
|
+
class="q-interact__item"
|
|
355
|
+
:class="{
|
|
356
|
+
'q-interact__item--active': isSelected(item),
|
|
357
|
+
'q-interact__item--dragging': drag?.id === item.id,
|
|
358
|
+
'q-interact__item--resizing': rs?.id === item.id,
|
|
359
|
+
}"
|
|
360
|
+
:style="itemStyle(item)"
|
|
361
|
+
@pointerdown="dragStart($event, item)"
|
|
362
|
+
@click="select(item)"
|
|
363
|
+
@dblclick="bringToFront(item)"
|
|
364
|
+
>
|
|
365
|
+
<div v-if="handle" class="q-interact__handle">
|
|
366
|
+
<slot name="handle" :item="item">
|
|
367
|
+
<span class="q-interact__grip"><Icon icon="lucide:grip-vertical" /></span>
|
|
368
|
+
</slot>
|
|
369
|
+
</div>
|
|
370
|
+
<div class="q-interact__content">
|
|
371
|
+
<slot name="item" :item="item" :index="local.indexOf(item)">
|
|
372
|
+
<div class="q-interact__default">
|
|
373
|
+
{{ item.title ?? String(item.id) }}
|
|
374
|
+
</div>
|
|
375
|
+
</slot>
|
|
376
|
+
</div>
|
|
377
|
+
<span
|
|
378
|
+
v-if="interactive && !readonly"
|
|
379
|
+
class="q-interact__resize"
|
|
380
|
+
title="Resize"
|
|
381
|
+
@pointerdown="resizeStart($event, item)"
|
|
382
|
+
/>
|
|
383
|
+
</div>
|
|
384
|
+
</template>
|
|
385
|
+
<slot v-if="!local.length" name="empty" />
|
|
386
|
+
</div>
|
|
387
|
+
</template>
|