@tekkare/romulus 0.1.207 → 0.1.209
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,330 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
RmDashboardGrid - une grille de tuiles que l'on deplace et redimensionne,
|
|
3
|
+
a la maniere des tableaux de bord PostHog ou Grafana.
|
|
4
|
+
|
|
5
|
+
La grille ne sait rien du contenu : elle place des cellules (`layout`) et
|
|
6
|
+
rend chaque tuile par le slot `#item`. Le calcul (chevauchements, tassement
|
|
7
|
+
vers le haut) vit dans utils/dashboardGrid, pur et teste a part ; ce
|
|
8
|
+
composant ne fait que traduire pixels et cellules.
|
|
9
|
+
|
|
10
|
+
Pendant un geste, la grille montre le resultat sans l'emettre : la tuile
|
|
11
|
+
tenue suit le pointeur, une empreinte montre ou elle tombera, les autres
|
|
12
|
+
s'ecartent. `update:layout` ne part qu'au lacher, une fois : l'app enregistre
|
|
13
|
+
un geste, pas chaque pixel.
|
|
14
|
+
|
|
15
|
+
On saisit une tuile par sa poignee, ou par toute zone que l'app marque de
|
|
16
|
+
`data-rm-dash-drag` (l'en-tete d'une carte, par exemple). Un bouton, un lien
|
|
17
|
+
ou un champ dans cette zone restent cliquables : un appui ne devient un
|
|
18
|
+
deplacement qu'au-dela de quelques pixels.
|
|
19
|
+
|
|
20
|
+
Clavier : la poignee est un bouton. Les fleches deplacent la tuile d'une
|
|
21
|
+
cellule, Maj + fleches la redimensionnent, et la nouvelle place est lue
|
|
22
|
+
(region `aria-live`). Sans ca, la disposition serait reservee a la souris.
|
|
23
|
+
|
|
24
|
+
En dessous de `stackBelow` pixels de large, les tuiles s'empilent en une
|
|
25
|
+
colonne dans l'ordre de lecture (haut puis gauche) et l'edition se coupe :
|
|
26
|
+
deplacer une tuile sur une colonne de telephone n'a pas de sens.
|
|
27
|
+
-->
|
|
28
|
+
<script setup lang="ts">
|
|
29
|
+
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
|
30
|
+
import { rmGridCompact, rmGridMove, type RmGridItem } from '../utils/dashboardGrid'
|
|
31
|
+
|
|
32
|
+
const props = withDefaults(defineProps<{
|
|
33
|
+
/** Les tuiles et leurs cellules. Seules les cles de RmGridItem sont lues. */
|
|
34
|
+
layout: RmGridItem[]
|
|
35
|
+
/** Mode edition : poignees, deplacement, redimensionnement. */
|
|
36
|
+
editable?: boolean
|
|
37
|
+
cols?: number
|
|
38
|
+
/** Hauteur d'une ligne de grille, en pixels. */
|
|
39
|
+
rowHeight?: number
|
|
40
|
+
/** Ecart entre deux tuiles, en pixels. */
|
|
41
|
+
gap?: number
|
|
42
|
+
/** Largeur en dessous de laquelle les tuiles s'empilent. */
|
|
43
|
+
stackBelow?: number
|
|
44
|
+
/** Libelle d'une tuile pour la poignee et l'annonce : son titre, en general. */
|
|
45
|
+
itemLabel?: (id: string) => string
|
|
46
|
+
lang?: 'fr' | 'en'
|
|
47
|
+
}>(), {
|
|
48
|
+
editable: false,
|
|
49
|
+
cols: 12,
|
|
50
|
+
rowHeight: 80,
|
|
51
|
+
gap: 16,
|
|
52
|
+
stackBelow: 640,
|
|
53
|
+
itemLabel: undefined,
|
|
54
|
+
lang: 'fr',
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
const emit = defineEmits<{
|
|
58
|
+
'update:layout': [layout: RmGridItem[]]
|
|
59
|
+
}>()
|
|
60
|
+
|
|
61
|
+
defineSlots<{
|
|
62
|
+
item(props: { item: RmGridItem; editing: boolean }): unknown
|
|
63
|
+
}>()
|
|
64
|
+
|
|
65
|
+
const T = computed(() => props.lang === 'en'
|
|
66
|
+
? {
|
|
67
|
+
grip: (name: string) => `Move ${name}. Arrow keys move, Shift and arrow keys resize.`,
|
|
68
|
+
resize: 'Resize',
|
|
69
|
+
at: (i: RmGridItem) => `Column ${i.x + 1}, row ${i.y + 1}, ${i.w} wide, ${i.h} high`,
|
|
70
|
+
}
|
|
71
|
+
: {
|
|
72
|
+
grip: (name: string) => `Déplacer ${name}. Les flèches déplacent, Maj et flèches redimensionnent.`,
|
|
73
|
+
resize: 'Redimensionner',
|
|
74
|
+
at: (i: RmGridItem) => `Colonne ${i.x + 1}, ligne ${i.y + 1}, largeur ${i.w}, hauteur ${i.h}`,
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
const root = ref<HTMLElement>()
|
|
78
|
+
const width = ref(0)
|
|
79
|
+
let observer: ResizeObserver | undefined
|
|
80
|
+
|
|
81
|
+
onMounted(() => {
|
|
82
|
+
if (!root.value) return
|
|
83
|
+
width.value = root.value.clientWidth
|
|
84
|
+
observer = new ResizeObserver(([entry]) => { width.value = entry!.contentRect.width })
|
|
85
|
+
observer.observe(root.value)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
const stacked = computed(() => width.value > 0 && width.value < props.stackBelow)
|
|
89
|
+
const editing = computed(() => props.editable && !stacked.value)
|
|
90
|
+
const colWidth = computed(() => (width.value - props.gap * (props.cols - 1)) / props.cols)
|
|
91
|
+
const stepX = computed(() => colWidth.value + props.gap)
|
|
92
|
+
const stepY = computed(() => props.rowHeight + props.gap)
|
|
93
|
+
|
|
94
|
+
const base = computed(() => rmGridCompact(props.layout.map(pick), props.cols))
|
|
95
|
+
|
|
96
|
+
function pick(item: RmGridItem): RmGridItem {
|
|
97
|
+
const { id, x, y, w, h, minW, minH } = item
|
|
98
|
+
return { id, x, y, w, h, minW, minH }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// --- Geste en cours ------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
interface Gesture {
|
|
104
|
+
id: string
|
|
105
|
+
mode: 'move' | 'resize'
|
|
106
|
+
startX: number
|
|
107
|
+
startY: number
|
|
108
|
+
origin: RmGridItem
|
|
109
|
+
/** Decalage du pointeur, en pixels. */
|
|
110
|
+
dx: number
|
|
111
|
+
dy: number
|
|
112
|
+
started: boolean
|
|
113
|
+
pointerId: number
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const gesture = ref<Gesture | null>(null)
|
|
117
|
+
|
|
118
|
+
/** La cellule visee par le geste. */
|
|
119
|
+
const target = computed(() => {
|
|
120
|
+
const g = gesture.value
|
|
121
|
+
if (!g?.started) return null
|
|
122
|
+
const o = g.origin
|
|
123
|
+
if (g.mode === 'move') {
|
|
124
|
+
return { x: Math.round(o.x + g.dx / stepX.value), y: Math.max(0, Math.round(o.y + g.dy / stepY.value)) }
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
w: Math.max(1, Math.round(o.w + g.dx / stepX.value)),
|
|
128
|
+
h: Math.max(1, Math.round(o.h + g.dy / stepY.value)),
|
|
129
|
+
}
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
const preview = computed(() =>
|
|
133
|
+
target.value && gesture.value ? rmGridMove(base.value, props.cols, gesture.value.id, target.value) : base.value,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
const shown = computed(() =>
|
|
137
|
+
stacked.value ? [...preview.value].sort((a, b) => a.y - b.y || a.x - b.x) : preview.value,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
const height = computed(() => {
|
|
141
|
+
const rows = preview.value.reduce((bottom, item) => Math.max(bottom, item.y + item.h), 0)
|
|
142
|
+
return rows ? rows * stepY.value - props.gap : 0
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
function rect(item: RmGridItem) {
|
|
146
|
+
return {
|
|
147
|
+
left: item.x * stepX.value,
|
|
148
|
+
top: item.y * stepY.value,
|
|
149
|
+
width: item.w * stepX.value - props.gap,
|
|
150
|
+
height: item.h * stepY.value - props.gap,
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function px(box: { left: number; top: number; width: number; height: number }) {
|
|
155
|
+
return {
|
|
156
|
+
transform: `translate(${box.left}px, ${box.top}px)`,
|
|
157
|
+
width: `${box.width}px`,
|
|
158
|
+
height: `${box.height}px`,
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function itemStyle(item: RmGridItem) {
|
|
163
|
+
// Empilees, les tuiles gardent leur hauteur au minimum mais suivent leur contenu.
|
|
164
|
+
if (stacked.value) return { minHeight: `${item.h * stepY.value - props.gap}px` }
|
|
165
|
+
const g = gesture.value
|
|
166
|
+
if (g?.started && g.id === item.id) {
|
|
167
|
+
// La tuile tenue suit le pointeur, depuis sa place d'origine.
|
|
168
|
+
const box = rect(g.origin)
|
|
169
|
+
if (g.mode === 'move') return px({ ...box, left: box.left + g.dx, top: box.top + g.dy })
|
|
170
|
+
return px({ ...box, width: Math.max(colWidth.value, box.width + g.dx), height: Math.max(props.rowHeight, box.height + g.dy) })
|
|
171
|
+
}
|
|
172
|
+
return px(rect(item))
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const placeholder = computed(() => {
|
|
176
|
+
const g = gesture.value
|
|
177
|
+
if (!g?.started) return null
|
|
178
|
+
const item = preview.value.find((i) => i.id === g.id)
|
|
179
|
+
return item ? px(rect(item)) : null
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
/** Ce qui reste cliquable meme dans une zone de saisie. */
|
|
183
|
+
const INTERACTIVE = 'a, input, select, textarea, [contenteditable], [data-rm-dash-nodrag], button:not(.rm-dash__grip)'
|
|
184
|
+
const THRESHOLD = 4
|
|
185
|
+
|
|
186
|
+
function onPointerDown(event: PointerEvent, item: RmGridItem, mode: 'move' | 'resize') {
|
|
187
|
+
if (!editing.value || event.button !== 0) return
|
|
188
|
+
const el = event.target as HTMLElement
|
|
189
|
+
if (mode === 'move') {
|
|
190
|
+
if (!el.closest('.rm-dash__grip, [data-rm-dash-drag]')) return
|
|
191
|
+
if (el.closest(INTERACTIVE)) return
|
|
192
|
+
}
|
|
193
|
+
gesture.value = {
|
|
194
|
+
id: item.id,
|
|
195
|
+
mode,
|
|
196
|
+
startX: event.clientX,
|
|
197
|
+
startY: event.clientY,
|
|
198
|
+
origin: { ...item },
|
|
199
|
+
dx: 0,
|
|
200
|
+
dy: 0,
|
|
201
|
+
started: mode === 'resize',
|
|
202
|
+
pointerId: event.pointerId,
|
|
203
|
+
}
|
|
204
|
+
window.addEventListener('pointermove', onPointerMove)
|
|
205
|
+
window.addEventListener('pointerup', onPointerUp)
|
|
206
|
+
window.addEventListener('pointercancel', cancel)
|
|
207
|
+
window.addEventListener('keydown', onEscape)
|
|
208
|
+
if (mode === 'resize') event.preventDefault()
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function onPointerMove(event: PointerEvent) {
|
|
212
|
+
const g = gesture.value
|
|
213
|
+
if (!g || event.pointerId !== g.pointerId) return
|
|
214
|
+
g.dx = event.clientX - g.startX
|
|
215
|
+
g.dy = event.clientY - g.startY
|
|
216
|
+
if (!g.started && Math.hypot(g.dx, g.dy) > THRESHOLD) {
|
|
217
|
+
g.started = true
|
|
218
|
+
// Plus de selection de texte pendant que l'on tient la tuile.
|
|
219
|
+
window.getSelection()?.removeAllRanges()
|
|
220
|
+
}
|
|
221
|
+
if (g.started) event.preventDefault()
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function onPointerUp() {
|
|
225
|
+
const g = gesture.value
|
|
226
|
+
const result = g?.started && target.value ? preview.value : null
|
|
227
|
+
stop()
|
|
228
|
+
if (g && result) commit(result, g.id)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function onEscape(event: KeyboardEvent) {
|
|
232
|
+
if (event.key === 'Escape') cancel()
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function cancel() {
|
|
236
|
+
stop()
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function stop() {
|
|
240
|
+
gesture.value = null
|
|
241
|
+
window.removeEventListener('pointermove', onPointerMove)
|
|
242
|
+
window.removeEventListener('pointerup', onPointerUp)
|
|
243
|
+
window.removeEventListener('pointercancel', cancel)
|
|
244
|
+
window.removeEventListener('keydown', onEscape)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
onBeforeUnmount(() => {
|
|
248
|
+
stop()
|
|
249
|
+
observer?.disconnect()
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
// --- Clavier ---------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
const announce = ref('')
|
|
255
|
+
|
|
256
|
+
const KEYS: Record<string, [number, number]> = {
|
|
257
|
+
ArrowLeft: [-1, 0],
|
|
258
|
+
ArrowRight: [1, 0],
|
|
259
|
+
ArrowUp: [0, -1],
|
|
260
|
+
ArrowDown: [0, 1],
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function onKey(event: KeyboardEvent, item: RmGridItem) {
|
|
264
|
+
const step = KEYS[event.key]
|
|
265
|
+
if (!step) return
|
|
266
|
+
event.preventDefault()
|
|
267
|
+
const [dx, dy] = step
|
|
268
|
+
const patch = event.shiftKey
|
|
269
|
+
? { w: Math.max(1, item.w + dx), h: Math.max(1, item.h + dy) }
|
|
270
|
+
: { x: item.x + dx, y: Math.max(0, item.y + dy) }
|
|
271
|
+
commit(rmGridMove(base.value, props.cols, item.id, patch), item.id)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function commit(next: RmGridItem[], id: string) {
|
|
275
|
+
const changed = next.some((item) => {
|
|
276
|
+
const before = base.value.find((b) => b.id === item.id)
|
|
277
|
+
return !before || before.x !== item.x || before.y !== item.y || before.w !== item.w || before.h !== item.h
|
|
278
|
+
})
|
|
279
|
+
const moved = next.find((item) => item.id === id)
|
|
280
|
+
if (moved) announce.value = T.value.at(moved)
|
|
281
|
+
if (changed) emit('update:layout', next)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function label(id: string) {
|
|
285
|
+
return props.itemLabel?.(id) ?? id
|
|
286
|
+
}
|
|
287
|
+
</script>
|
|
288
|
+
|
|
289
|
+
<template>
|
|
290
|
+
<div
|
|
291
|
+
ref="root"
|
|
292
|
+
class="rm-dash"
|
|
293
|
+
:class="{ 'rm-dash--stacked': stacked, 'rm-dash--editing': editing, 'rm-dash--moving': gesture?.started }"
|
|
294
|
+
:style="stacked ? undefined : { height: `${height}px` }"
|
|
295
|
+
>
|
|
296
|
+
<div v-if="placeholder" class="rm-dash__placeholder" :style="placeholder" aria-hidden="true" />
|
|
297
|
+
<div
|
|
298
|
+
v-for="item in shown"
|
|
299
|
+
:key="item.id"
|
|
300
|
+
class="rm-dash__item"
|
|
301
|
+
:class="{ 'rm-dash__item--held': gesture?.started && gesture.id === item.id }"
|
|
302
|
+
:style="width ? itemStyle(item) : undefined"
|
|
303
|
+
@pointerdown="onPointerDown($event, item, 'move')"
|
|
304
|
+
>
|
|
305
|
+
<slot name="item" :item="item" :editing="editing" />
|
|
306
|
+
<template v-if="editing">
|
|
307
|
+
<button
|
|
308
|
+
type="button"
|
|
309
|
+
class="rm-dash__grip"
|
|
310
|
+
:aria-label="T.grip(label(item.id))"
|
|
311
|
+
:title="T.grip(label(item.id))"
|
|
312
|
+
@keydown="onKey($event, item)"
|
|
313
|
+
>
|
|
314
|
+
<RmIcon name="DotsSix" :size="16" weight="bold" />
|
|
315
|
+
</button>
|
|
316
|
+
<span
|
|
317
|
+
class="rm-dash__resize"
|
|
318
|
+
:title="T.resize"
|
|
319
|
+
aria-hidden="true"
|
|
320
|
+
@pointerdown.stop="onPointerDown($event, item, 'resize')"
|
|
321
|
+
/>
|
|
322
|
+
</template>
|
|
323
|
+
</div>
|
|
324
|
+
<p class="rm-dash__live" aria-live="polite">{{ announce }}</p>
|
|
325
|
+
</div>
|
|
326
|
+
</template>
|
|
327
|
+
|
|
328
|
+
<style scoped>
|
|
329
|
+
.rm-dash{position:relative;width:100%}.rm-dash__item{display:flex;flex-direction:column;left:0;min-width:0;position:absolute;top:0;transition:transform var(--duration-base) var(--ease-out),width var(--duration-base) var(--ease-out),height var(--duration-base) var(--ease-out)}.rm-dash__item>:deep(:first-child){flex:1;min-height:0}.rm-dash--stacked{display:flex;flex-direction:column;gap:var(--space-16)}.rm-dash--stacked .rm-dash__item{position:relative;transform:none}.rm-dash__item--held{border-radius:var(--radius-lg);box-shadow:var(--shadow-lg);cursor:grabbing;transition:none;z-index:3}.rm-dash--moving{-webkit-user-select:none;-moz-user-select:none;user-select:none}.rm-dash--editing :deep([data-rm-dash-drag]){cursor:grab;touch-action:none}.rm-dash__placeholder{background:color-mix(in srgb,var(--color-primary) 8%,transparent);border:2px dashed var(--color-primary);border-radius:var(--radius-lg);left:0;position:absolute;top:0;transition:transform var(--duration-fast) var(--ease-out),width var(--duration-fast) var(--ease-out),height var(--duration-fast) var(--ease-out);z-index:0}.rm-dash__grip{align-items:center;background:var(--bg-surface-2);border-radius:999px;color:var(--text-muted);cursor:grab;display:inline-flex;height:16px;justify-content:center;left:50%;padding:0;position:absolute;top:4px;touch-action:none;transform:translateX(-50%);width:36px;z-index:2}.rm-dash__grip:hover{color:var(--text-primary)}.rm-dash__grip:focus-visible{box-shadow:var(--shadow-focus);color:var(--text-primary);outline:none}.rm-dash__resize{background:linear-gradient(135deg,transparent 55%,var(--text-muted) 55%,var(--text-muted) 62%,transparent 62%),linear-gradient(135deg,transparent 75%,var(--text-muted) 75%,var(--text-muted) 82%,transparent 82%);bottom:2px;cursor:nwse-resize;height:14px;position:absolute;right:2px;touch-action:none;width:14px;z-index:2}.rm-dash__live{clip-path:inset(50%);height:1px;overflow:hidden;position:absolute;white-space:nowrap;width:1px}@media (prefers-reduced-motion:reduce){.rm-dash__item,.rm-dash__placeholder{transition:none}}
|
|
330
|
+
</style>
|
|
@@ -70,7 +70,15 @@ const items = computed<RmSegmentedOption[]>(() =>
|
|
|
70
70
|
const activeIndex = computed(() => items.value.findIndex(item => item.value === model.value))
|
|
71
71
|
|
|
72
72
|
const root = ref<HTMLElement | null>(null)
|
|
73
|
-
|
|
73
|
+
/**
|
|
74
|
+
* Les segments se relisent dans le DOM, dans son ordre. Un tableau de refs
|
|
75
|
+
* pose sur un v-for ne garantit pas l'ordre des options : quand elles
|
|
76
|
+
* changent (un autre jeu de choix pour le meme rail), le nouvel element etait
|
|
77
|
+
* ajoute en fin de tableau et le pave se posait sous le mauvais segment.
|
|
78
|
+
*/
|
|
79
|
+
function segments(): HTMLElement[] {
|
|
80
|
+
return root.value ? Array.from(root.value.querySelectorAll<HTMLElement>(':scope > .rm-seg__option')) : []
|
|
81
|
+
}
|
|
74
82
|
|
|
75
83
|
/**
|
|
76
84
|
* Le pave n'existe pas tant qu'il n'a pas ete mesure : sans ca il apparait un
|
|
@@ -80,7 +88,7 @@ const segments = ref<HTMLElement[]>([])
|
|
|
80
88
|
const thumb = ref<Record<string, string> | null>(null)
|
|
81
89
|
|
|
82
90
|
function measure() {
|
|
83
|
-
const element = segments
|
|
91
|
+
const element = segments()[activeIndex.value]
|
|
84
92
|
if (!element || !root.value) {
|
|
85
93
|
thumb.value = null
|
|
86
94
|
return
|
|
@@ -129,7 +137,7 @@ function observeAll() {
|
|
|
129
137
|
if (!observer) return
|
|
130
138
|
observer.disconnect()
|
|
131
139
|
if (root.value) observer.observe(root.value)
|
|
132
|
-
segments.
|
|
140
|
+
segments().forEach(element => observer!.observe(element))
|
|
133
141
|
}
|
|
134
142
|
|
|
135
143
|
onMounted(() => {
|
|
@@ -150,7 +158,9 @@ onBeforeUnmount(() => {
|
|
|
150
158
|
observer = null
|
|
151
159
|
})
|
|
152
160
|
|
|
153
|
-
|
|
161
|
+
// Les valeurs, pas seulement leur nombre : un autre jeu de trois options
|
|
162
|
+
// garde la meme longueur et doit tout de meme deplacer le pave.
|
|
163
|
+
watch(() => [model.value, items.value.map(item => `${item.value}:${item.label}`).join('|'), props.block], () => {
|
|
154
164
|
remeasure()
|
|
155
165
|
nextTick(observeAll)
|
|
156
166
|
})
|
|
@@ -173,7 +183,6 @@ watch(() => [model.value, items.value.length, props.block], () => {
|
|
|
173
183
|
<button
|
|
174
184
|
v-for="(item, index) in items"
|
|
175
185
|
:key="item.value"
|
|
176
|
-
ref="segments"
|
|
177
186
|
class="rm-seg__option"
|
|
178
187
|
:class="{
|
|
179
188
|
'rm-seg__option--active': index === activeIndex,
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Calcul de la grille de RmDashboardGrid : des tuiles posees en cellules
|
|
3
|
+
* (colonnes fixes, lignes infinies), sans chevauchement, tassees vers le haut.
|
|
4
|
+
*
|
|
5
|
+
* Pur et sans DOM : le composant ne fait que convertir pixels et cellules. La
|
|
6
|
+
* regle est celle des tableaux de bord PostHog ou Grafana : une tuile deplacee
|
|
7
|
+
* prend la place visee, celles qu'elle touche descendent, puis tout remonte
|
|
8
|
+
* dans les trous. Un trou ne survit jamais a un geste.
|
|
9
|
+
*/
|
|
10
|
+
export interface RmGridItem {
|
|
11
|
+
id: string;
|
|
12
|
+
x: number;
|
|
13
|
+
y: number;
|
|
14
|
+
w: number;
|
|
15
|
+
h: number;
|
|
16
|
+
minW?: number;
|
|
17
|
+
minH?: number;
|
|
18
|
+
}
|
|
19
|
+
/** Une tuile ramenee dans la grille : taille entre son minimum et `cols`, x dans les bornes. */
|
|
20
|
+
export declare function rmGridClamp(item: RmGridItem, cols: number): RmGridItem;
|
|
21
|
+
/**
|
|
22
|
+
* Tasse la grille vers le haut et defait les chevauchements.
|
|
23
|
+
*
|
|
24
|
+
* `pinned` garde sa place pendant un geste : c'est la tuile que l'on tient, les
|
|
25
|
+
* autres s'ecartent pour elle. Rend les tuiles dans l'ordre recu.
|
|
26
|
+
*/
|
|
27
|
+
export declare function rmGridCompact(items: RmGridItem[], cols: number, pinned?: string): RmGridItem[];
|
|
28
|
+
/**
|
|
29
|
+
* La grille apres avoir pose `id` en `patch` (position et/ou taille) : la
|
|
30
|
+
* tuile prend la place visee, les autres s'ecartent, puis tout se tasse.
|
|
31
|
+
*/
|
|
32
|
+
export declare function rmGridMove(items: RmGridItem[], cols: number, id: string, patch: Partial<Pick<RmGridItem, 'x' | 'y' | 'w' | 'h'>>): RmGridItem[];
|
|
33
|
+
/** La premiere ligne libre sous toutes les tuiles : la ou poser une tuile ajoutee. */
|
|
34
|
+
export declare function rmGridBottom(items: RmGridItem[]): number;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
function collide(a, b) {
|
|
2
|
+
return a.id !== b.id && a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
|
|
3
|
+
}
|
|
4
|
+
export function rmGridClamp(item, cols) {
|
|
5
|
+
const w = Math.min(cols, Math.max(item.minW ?? 1, Math.round(item.w)));
|
|
6
|
+
const h = Math.max(item.minH ?? 1, Math.round(item.h));
|
|
7
|
+
const x = Math.min(cols - w, Math.max(0, Math.round(item.x)));
|
|
8
|
+
return { ...item, x, y: Math.max(0, Math.round(item.y)), w, h };
|
|
9
|
+
}
|
|
10
|
+
export function rmGridCompact(items, cols, pinned) {
|
|
11
|
+
const clamped = items.map((item) => rmGridClamp(item, cols));
|
|
12
|
+
const placed = clamped.filter((item) => item.id === pinned);
|
|
13
|
+
const rest = clamped.filter((item) => item.id !== pinned).sort((a, b) => a.y - b.y || a.x - b.x);
|
|
14
|
+
for (const item of rest) {
|
|
15
|
+
while (item.y > 0 && !placed.some((other) => collide({ ...item, y: item.y - 1 }, other))) item.y--;
|
|
16
|
+
let hit = placed.find((other) => collide(item, other));
|
|
17
|
+
while (hit) {
|
|
18
|
+
item.y = hit.y + hit.h;
|
|
19
|
+
hit = placed.find((other) => collide(item, other));
|
|
20
|
+
}
|
|
21
|
+
placed.push(item);
|
|
22
|
+
}
|
|
23
|
+
const byId = new Map(placed.map((item) => [item.id, item]));
|
|
24
|
+
return clamped.map((item) => byId.get(item.id));
|
|
25
|
+
}
|
|
26
|
+
export function rmGridMove(items, cols, id, patch) {
|
|
27
|
+
const moved = items.map((item) => item.id === id ? { ...item, ...patch } : item);
|
|
28
|
+
return rmGridCompact(rmGridCompact(moved, cols, id), cols);
|
|
29
|
+
}
|
|
30
|
+
export function rmGridBottom(items) {
|
|
31
|
+
return items.reduce((bottom, item) => Math.max(bottom, item.y + item.h), 0);
|
|
32
|
+
}
|