@dnax/ui 0.0.27 → 0.0.29
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/QDialog.vue +26 -12
- package/components/QEditorJs.vue +146 -0
- package/components/QFab.vue +19 -2
- package/components/QInteract.vue +387 -0
- package/components/QSpreadsheet.vue +4662 -0
- package/components/QSyntax.vue +22 -9
- package/components/QTiptap.vue +993 -0
- package/components/internal/QTiptapTableView.vue +123 -0
- package/components/internal/QTiptapTaskItemView.vue +49 -0
- package/index.ts +18 -0
- package/lib/config.ts +6 -0
- package/lib/formula.test.ts +206 -0
- package/lib/formula.ts +292 -0
- package/lib/icons.ts +43 -0
- package/lib/intersection.ts +118 -0
- package/lib/q.ts +5 -1
- package/lib/spreadsheet.test.ts +92 -0
- package/lib/spreadsheet.ts +175 -0
- package/lib/storage.ts +175 -0
- package/lib/tiptap-table.ts +19 -0
- package/lib/tiptap-task-list.ts +16 -0
- package/lib/touchHold.ts +221 -0
- package/lib/touchPan.ts +237 -0
- package/lib/touchRepeat.ts +148 -0
- package/lib/touchSwipe.ts +182 -0
- package/lib/useComponentProps.ts +7 -0
- package/module.ts +10 -0
- package/package.json +27 -1
- package/scripts/generate-exports.ts +12 -0
- package/styles/main.css +2217 -32
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Helpers purs du tableur QSpreadsheet — extraits du composant pour être
|
|
2
|
+
// testables unitairement (bun test). Aucune dépendance DOM / Vue.
|
|
3
|
+
|
|
4
|
+
/** Index de colonne (0-based) → lettres Excel : 0 → "A", 25 → "Z", 26 → "AA" */
|
|
5
|
+
export const colLetter = (i: number): string => {
|
|
6
|
+
let n = i
|
|
7
|
+
let s = ""
|
|
8
|
+
while (n >= 0) {
|
|
9
|
+
s = String.fromCharCode(65 + (n % 26)) + s
|
|
10
|
+
n = Math.floor(n / 26) - 1
|
|
11
|
+
}
|
|
12
|
+
return s
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Lettres Excel → index de colonne 0-based : "A" → 0, "AA" → 26 */
|
|
16
|
+
export const colFromLetters = (s: string): number => {
|
|
17
|
+
let n = 0
|
|
18
|
+
for (const ch of s.toUpperCase()) n = n * 26 + (ch.charCodeAt(0) - 64)
|
|
19
|
+
return n - 1
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Cellule « vide » : null, undefined ou chaîne vide */
|
|
23
|
+
export const isBlankValue = (v: any): boolean =>
|
|
24
|
+
v === null || v === undefined || v === ""
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Décale les références A1 non absolues d'une formule de (dr, dc) —
|
|
28
|
+
* plages incluses, `$` préservés (copie / autofill).
|
|
29
|
+
*/
|
|
30
|
+
export const shiftFormulaRefs = (src: string, dr: number, dc: number): string =>
|
|
31
|
+
src.replace(
|
|
32
|
+
/(\$?)([A-Za-z]{1,3})(\$?)(\d+)/g,
|
|
33
|
+
(_m: string, ca: string, L: string, ra: string, D: string) => {
|
|
34
|
+
const col = colFromLetters(L) + (ca === "$" ? 0 : dc)
|
|
35
|
+
const row = Number(D) - 1 + (ra === "$" ? 0 : dr)
|
|
36
|
+
if (col < 0 || row < 0) return _m
|
|
37
|
+
return ca + colLetter(col) + ra + (row + 1)
|
|
38
|
+
},
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
/** Date ISO AAAA-MM-JJ */
|
|
42
|
+
export const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/
|
|
43
|
+
|
|
44
|
+
/** Ajoute des jours à une date ISO (UTC, évite le décalage de fuseau) */
|
|
45
|
+
export const isoAddDays = (iso: string, days: number): string => {
|
|
46
|
+
const d = new Date(iso + "T00:00:00Z")
|
|
47
|
+
d.setUTCDate(d.getUTCDate() + days)
|
|
48
|
+
return d.toISOString().slice(0, 10)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Échappe une valeur pour un CSV (quote si délimiteur / guillemet / newline) */
|
|
52
|
+
export const csvEscape = (s: unknown, delim: string): string => {
|
|
53
|
+
const str = s === null || s === undefined ? "" : String(s)
|
|
54
|
+
return str.includes(delim) || str.includes('"') || /[\n\r]/.test(str)
|
|
55
|
+
? '"' + str.replace(/"/g, '""') + '"'
|
|
56
|
+
: str
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Décode une ligne CSV (guillemets doublés, délimiteur multi-caractère géré
|
|
61
|
+
* comme un caractère unique ; retourne les cellules décodées).
|
|
62
|
+
*/
|
|
63
|
+
export const csvSplitLine = (line: string, delim = ","): string[] => {
|
|
64
|
+
const out: string[] = []
|
|
65
|
+
let cur = ""
|
|
66
|
+
let inQ = false
|
|
67
|
+
let i = 0
|
|
68
|
+
const isDelim = (at: number) => line.startsWith(delim, at)
|
|
69
|
+
while (i < line.length) {
|
|
70
|
+
const ch = line[i]!
|
|
71
|
+
if (inQ) {
|
|
72
|
+
if (ch === '"') {
|
|
73
|
+
if (line[i + 1] === '"') {
|
|
74
|
+
cur += '"'
|
|
75
|
+
i += 2
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
inQ = false
|
|
79
|
+
i++
|
|
80
|
+
continue
|
|
81
|
+
}
|
|
82
|
+
cur += ch
|
|
83
|
+
i++
|
|
84
|
+
continue
|
|
85
|
+
}
|
|
86
|
+
if (ch === '"') {
|
|
87
|
+
inQ = true
|
|
88
|
+
i++
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
if (isDelim(i)) {
|
|
92
|
+
out.push(cur)
|
|
93
|
+
cur = ""
|
|
94
|
+
i += delim.length
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
cur += ch
|
|
98
|
+
i++
|
|
99
|
+
}
|
|
100
|
+
out.push(cur)
|
|
101
|
+
return out
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Parse un texte CSV complet (CRLF ou LF, guillemets) en lignes de cellules.
|
|
106
|
+
*/
|
|
107
|
+
export const parseCsv = (text: string, delim = ","): string[][] => {
|
|
108
|
+
const lines: string[][] = []
|
|
109
|
+
const current: string[] = []
|
|
110
|
+
let cell = ""
|
|
111
|
+
let inQ = false
|
|
112
|
+
let sawDelim = false
|
|
113
|
+
let i = 0
|
|
114
|
+
const isDelim = (at: number) => text.startsWith(delim, at)
|
|
115
|
+
const pushCell = () => {
|
|
116
|
+
current.push(cell)
|
|
117
|
+
cell = ""
|
|
118
|
+
}
|
|
119
|
+
while (i < text.length) {
|
|
120
|
+
const ch = text[i]!
|
|
121
|
+
if (inQ) {
|
|
122
|
+
if (ch === '"') {
|
|
123
|
+
if (text[i + 1] === '"') {
|
|
124
|
+
cell += '"'
|
|
125
|
+
i += 2
|
|
126
|
+
continue
|
|
127
|
+
}
|
|
128
|
+
inQ = false
|
|
129
|
+
i++
|
|
130
|
+
continue
|
|
131
|
+
}
|
|
132
|
+
cell += ch
|
|
133
|
+
i++
|
|
134
|
+
continue
|
|
135
|
+
}
|
|
136
|
+
if (ch === '"') {
|
|
137
|
+
inQ = true
|
|
138
|
+
i++
|
|
139
|
+
continue
|
|
140
|
+
}
|
|
141
|
+
if (isDelim(i)) {
|
|
142
|
+
pushCell()
|
|
143
|
+
sawDelim = true
|
|
144
|
+
i += delim.length
|
|
145
|
+
continue
|
|
146
|
+
}
|
|
147
|
+
if (ch === "\n") {
|
|
148
|
+
pushCell()
|
|
149
|
+
lines.push(current.splice(0))
|
|
150
|
+
sawDelim = false
|
|
151
|
+
i++
|
|
152
|
+
continue
|
|
153
|
+
}
|
|
154
|
+
if (ch === "\r") {
|
|
155
|
+
// les retours \r\n sont déjà gérés par le \n
|
|
156
|
+
i++
|
|
157
|
+
continue
|
|
158
|
+
}
|
|
159
|
+
cell += ch
|
|
160
|
+
i++
|
|
161
|
+
}
|
|
162
|
+
pushCell()
|
|
163
|
+
const onlyTrailingNewline = current.length === 1 && current[0] === "" && !sawDelim
|
|
164
|
+
if (lines.length === 0 || !onlyTrailingNewline) lines.push(current)
|
|
165
|
+
return lines
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Colonnes déduites des clés d'un jeu de lignes (type texte) */
|
|
169
|
+
export const deriveCols = (
|
|
170
|
+
rows: Record<string, any>[],
|
|
171
|
+
): { name: string; type: "text" }[] => {
|
|
172
|
+
const keys = new Set<string>()
|
|
173
|
+
for (const r of rows) Object.keys(r).forEach((k) => keys.add(k))
|
|
174
|
+
return [...keys].map((k) => ({ name: k, type: "text" as const }))
|
|
175
|
+
}
|
package/lib/storage.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// $q.localStorage / $q.sessionStorage — stockage web façon Quasar
|
|
2
|
+
// (https://quasar.dev/quasar-plugins/web-storage).
|
|
3
|
+
//
|
|
4
|
+
// Les valeurs sont sérialisées avec leur type d'origine (Date, RegExp, number,
|
|
5
|
+
// boolean, string, objets/tableaux) grâce à un encodage préfixé — le format
|
|
6
|
+
// `__q_*` est volontairement IDENTIQUE à celui de Quasar : des clés écrites par
|
|
7
|
+
// une app Quasar restent lisibles par dnax.ui et inversement.
|
|
8
|
+
//
|
|
9
|
+
// Autres types (undefined, null, bigint, symbol, fonction) : stockés sous forme
|
|
10
|
+
// de string (comportement Quasar).
|
|
11
|
+
//
|
|
12
|
+
// SSR-safe : côté serveur (ou quand le Web Storage est indisponible — mode
|
|
13
|
+
// privé, iframe sandbox…), toutes les méthodes deviennent des no-op et
|
|
14
|
+
// getItem/getIndex/getKey renvoient null, getAll {}, getAllKeys [].
|
|
15
|
+
|
|
16
|
+
export interface QWebStorage {
|
|
17
|
+
/** true si la clé existe */
|
|
18
|
+
has(key: string): boolean
|
|
19
|
+
/** Alias de has() */
|
|
20
|
+
hasItem(key: string): boolean
|
|
21
|
+
/** Nombre de clés stockées */
|
|
22
|
+
getLength(): number
|
|
23
|
+
/** Valeur (typée) de la clé, ou null si absente */
|
|
24
|
+
getItem<T = unknown>(key: string): T | null
|
|
25
|
+
/** Valeur (typée) stockée à l'index donné (ordre du Web Storage), ou null */
|
|
26
|
+
getIndex<T = unknown>(index: number): T | null
|
|
27
|
+
/** Clé stockée à l'index donné, ou null */
|
|
28
|
+
getKey(index: number): string | null
|
|
29
|
+
/** Toutes les entrées : { clé: valeur typée } */
|
|
30
|
+
getAll(): Record<string, unknown>
|
|
31
|
+
/** Toutes les clés, dans l'ordre du Web Storage */
|
|
32
|
+
getAllKeys(): string[]
|
|
33
|
+
/** Stocke une valeur (sérialisation typée) — alias de setItem() */
|
|
34
|
+
set(key: string, value: unknown): void
|
|
35
|
+
/** Stocke une valeur (sérialisation typée) */
|
|
36
|
+
setItem(key: string, value: unknown): void
|
|
37
|
+
/** Supprime la clé — alias de removeItem() */
|
|
38
|
+
remove(key: string): void
|
|
39
|
+
/** Supprime la clé */
|
|
40
|
+
removeItem(key: string): void
|
|
41
|
+
/** Vide tout le stockage */
|
|
42
|
+
clear(): void
|
|
43
|
+
/** true si le stockage est vide */
|
|
44
|
+
isEmpty(): boolean
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// — Sérialisation typée (format compatible Quasar) —
|
|
48
|
+
// Encode : `__q_<type>|<payload>` — tout type supporté ré-encode ses données
|
|
49
|
+
// pour que le Web Storage (qui ne stocke que des strings) restitue le type.
|
|
50
|
+
|
|
51
|
+
function encode(value: unknown): string {
|
|
52
|
+
if (value instanceof Date) return `__q_date|${value.getTime()}`
|
|
53
|
+
if (value instanceof RegExp) return `__q_expr|${value.source}`
|
|
54
|
+
switch (typeof value) {
|
|
55
|
+
case "number": return `__q_numb|${value}`
|
|
56
|
+
case "boolean": return `__q_bool|${value ? "1" : "0"}`
|
|
57
|
+
case "string": return `__q_strn|${value}`
|
|
58
|
+
case "function": return `__q_strn|${value.toString()}`
|
|
59
|
+
case "object":
|
|
60
|
+
if (value !== null) return `__q_objt|${JSON.stringify(value)}`
|
|
61
|
+
break
|
|
62
|
+
}
|
|
63
|
+
// Type non supporté (undefined, null, bigint, symbol…) : stocké tel quel
|
|
64
|
+
// (le Web Storage le convertit en string — même résultat côté lecture).
|
|
65
|
+
return String(value)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const NUMBER_RE = /^-?\d+$/
|
|
69
|
+
|
|
70
|
+
function decode(value: string): unknown {
|
|
71
|
+
// Valeur non encodée par nous (< préfixe, ou préfixe inconnu) : telle quelle
|
|
72
|
+
if (value.length < 9) return value
|
|
73
|
+
const type = value.slice(0, 8)
|
|
74
|
+
const source = value.slice(9)
|
|
75
|
+
|
|
76
|
+
switch (type) {
|
|
77
|
+
case "__q_date": {
|
|
78
|
+
// Horodatage numérique (format actuel) ou string ISO (anciens formats)
|
|
79
|
+
return new Date(NUMBER_RE.test(source) ? Number.parseInt(source, 10) : source)
|
|
80
|
+
}
|
|
81
|
+
case "__q_expr":
|
|
82
|
+
return new RegExp(source)
|
|
83
|
+
case "__q_numb":
|
|
84
|
+
return Number(source)
|
|
85
|
+
case "__q_bool":
|
|
86
|
+
return source === "1"
|
|
87
|
+
case "__q_strn":
|
|
88
|
+
return source
|
|
89
|
+
case "__q_objt": {
|
|
90
|
+
try {
|
|
91
|
+
return JSON.parse(source)
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// Payload corrompu → valeur brute plutôt que de faire planter getItem()
|
|
95
|
+
return value
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
default:
|
|
99
|
+
return value
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Instance no-op pour SSR / Web Storage indisponible (miroir de Quasar :
|
|
104
|
+
// getEmptyStorage) — jamais de throw ni d'accès à window.
|
|
105
|
+
function emptyStorage(): QWebStorage {
|
|
106
|
+
const noop = () => {}
|
|
107
|
+
return {
|
|
108
|
+
has: () => false,
|
|
109
|
+
hasItem: () => false,
|
|
110
|
+
getLength: () => 0,
|
|
111
|
+
getItem: () => null,
|
|
112
|
+
getIndex: () => null,
|
|
113
|
+
getKey: () => null,
|
|
114
|
+
getAll: () => ({}),
|
|
115
|
+
getAllKeys: () => [],
|
|
116
|
+
set: noop,
|
|
117
|
+
setItem: noop,
|
|
118
|
+
remove: noop,
|
|
119
|
+
removeItem: noop,
|
|
120
|
+
clear: noop,
|
|
121
|
+
isEmpty: () => true,
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function init(type: "local" | "session"): QWebStorage {
|
|
126
|
+
try {
|
|
127
|
+
if (typeof window === "undefined") return emptyStorage()
|
|
128
|
+
const ws = type === "local" ? window.localStorage : window.sessionStorage
|
|
129
|
+
if (!ws) return emptyStorage()
|
|
130
|
+
|
|
131
|
+
const get = (key: string): unknown => {
|
|
132
|
+
const item = ws.getItem(key)
|
|
133
|
+
return item === null ? null : decode(item)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
has: (key) => ws.getItem(key) !== null,
|
|
138
|
+
hasItem: (key) => ws.getItem(key) !== null,
|
|
139
|
+
getLength: () => ws.length,
|
|
140
|
+
getItem: <T = unknown>(key: string): T | null => get(key) as T | null,
|
|
141
|
+
getIndex: <T = unknown>(index: number): T | null =>
|
|
142
|
+
index < ws.length ? (get(ws.key(index) as string) as T | null) : null,
|
|
143
|
+
getKey: (index) => (index < ws.length ? ws.key(index) : null),
|
|
144
|
+
getAll: () => {
|
|
145
|
+
const result: Record<string, unknown> = {}
|
|
146
|
+
for (let i = 0; i < ws.length; i++) {
|
|
147
|
+
const key = ws.key(i) as string
|
|
148
|
+
result[key] = get(key)
|
|
149
|
+
}
|
|
150
|
+
return result
|
|
151
|
+
},
|
|
152
|
+
getAllKeys: () => {
|
|
153
|
+
const result: string[] = []
|
|
154
|
+
for (let i = 0; i < ws.length; i++) result.push(ws.key(i) as string)
|
|
155
|
+
return result
|
|
156
|
+
},
|
|
157
|
+
set: (key, value) => ws.setItem(key, encode(value)),
|
|
158
|
+
setItem: (key, value) => ws.setItem(key, encode(value)),
|
|
159
|
+
remove: (key) => ws.removeItem(key),
|
|
160
|
+
removeItem: (key) => ws.removeItem(key),
|
|
161
|
+
clear: () => ws.clear(),
|
|
162
|
+
isEmpty: () => ws.length === 0,
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
// Accès bloqué (mode privé, cookies désactivés…) → instance no-op
|
|
167
|
+
return emptyStorage()
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** $q.localStorage — stockage persistant (API Quasar Web Storage) */
|
|
172
|
+
export const localStorage: QWebStorage = init("local")
|
|
173
|
+
|
|
174
|
+
/** $q.sessionStorage — stockage par session (API Quasar Web Storage) */
|
|
175
|
+
export const sessionStorage: QWebStorage = init("session")
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Tables Tiptap pour QTiptap — le nœud « table » est rendu par une NodeView
|
|
2
|
+
// Vue (QTiptapTableView) au lieu du rendu DOM/colgroup par défaut. On garde
|
|
3
|
+
// les nœuds officiels TableRow/TableHeader/TableCell (schéma, keymaps,
|
|
4
|
+
// commandes) ; on ne remplace que le addNodeView de « table ».
|
|
5
|
+
import { Table, TableHeader, TableCell, TableRow } from "@tiptap/extension-table"
|
|
6
|
+
import { VueNodeViewRenderer } from "@tiptap/vue-3"
|
|
7
|
+
import type { NodeViewProps } from "@tiptap/vue-3"
|
|
8
|
+
import type { Component } from "vue"
|
|
9
|
+
import QTiptapTableView from "../components/internal/QTiptapTableView.vue"
|
|
10
|
+
|
|
11
|
+
export { TableHeader, TableCell, TableRow }
|
|
12
|
+
|
|
13
|
+
export const QTipTapTable = Table.extend({
|
|
14
|
+
addNodeView() {
|
|
15
|
+
// defineProps(nodeViewProps) ne fait pas remonter les types vers le renderer
|
|
16
|
+
// → cast vers Component<NodeViewProps>
|
|
17
|
+
return VueNodeViewRenderer(QTiptapTableView as Component<NodeViewProps>)
|
|
18
|
+
},
|
|
19
|
+
})
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Task list Tiptap pour QTiptap — l'élément de liste est rendu par une
|
|
2
|
+
// NodeView Vue (q-checkbox dnax.ui) : on garde l'extension officielle
|
|
3
|
+
// TaskItem (schéma, parse/render, keymap, input rules) et on ne remplace que
|
|
4
|
+
// son addNodeView par VueNodeViewRenderer(QTiptapTaskItemView).
|
|
5
|
+
import TaskList from "@tiptap/extension-task-list"
|
|
6
|
+
import TaskItem from "@tiptap/extension-task-item"
|
|
7
|
+
import { VueNodeViewRenderer } from "@tiptap/vue-3"
|
|
8
|
+
import QTiptapTaskItemView from "../components/internal/QTiptapTaskItemView.vue"
|
|
9
|
+
|
|
10
|
+
export { TaskList }
|
|
11
|
+
|
|
12
|
+
export const QTipTapTaskItem = TaskItem.extend({
|
|
13
|
+
addNodeView() {
|
|
14
|
+
return VueNodeViewRenderer(QTiptapTaskItemView)
|
|
15
|
+
},
|
|
16
|
+
})
|
package/lib/touchHold.ts
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// v-touch-hold — directive : détecte l'appui long (hold) sur un élément,
|
|
2
|
+
// tactile ET souris (modifier .mouse), équivalent Quasar
|
|
3
|
+
// https://quasar.dev/vue-directives/touch-hold
|
|
4
|
+
//
|
|
5
|
+
// Usage :
|
|
6
|
+
// <div v-touch-hold="handler" /> → 600ms, sensibilité 5/7px
|
|
7
|
+
// <div v-touch-hold.mouse="{ time: 400, sensitivity: 6 }" />
|
|
8
|
+
// <div v-touch-hold="'400:8:10'" /> → 400ms, 8px tactile, 10px souris
|
|
9
|
+
// <div v-touch-hold="350" />
|
|
10
|
+
// modifiers : mouse (inclut la souris), capture / mouseCapture, stop, prevent,
|
|
11
|
+
// passive
|
|
12
|
+
//
|
|
13
|
+
// Détails du handler (compatible Quasar) :
|
|
14
|
+
// { evt, position: { left, top }, duration }
|
|
15
|
+
// Après déclenchement, le geste est « consommé » : le click/touchend qui suit
|
|
16
|
+
// est supprimé (un appui long ne déclenche pas aussi un clic).
|
|
17
|
+
|
|
18
|
+
export interface TouchHoldDetails {
|
|
19
|
+
evt: Event
|
|
20
|
+
position: { left: number; top: number }
|
|
21
|
+
duration: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type TouchHoldHandler = (details: TouchHoldDetails) => void
|
|
25
|
+
|
|
26
|
+
const DEFAULTS = { time: 600, touchSensitivity: 5, mouseSensitivity: 7 }
|
|
27
|
+
|
|
28
|
+
interface HoldOptions {
|
|
29
|
+
handler: TouchHoldHandler
|
|
30
|
+
time: number
|
|
31
|
+
touchSensitivity: number
|
|
32
|
+
mouseSensitivity: number
|
|
33
|
+
mouse: boolean
|
|
34
|
+
stop: boolean
|
|
35
|
+
prevent: boolean
|
|
36
|
+
capture: boolean
|
|
37
|
+
passive: boolean
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type QElement = HTMLElement & { __qTouchHoldCleanup?: () => void }
|
|
41
|
+
|
|
42
|
+
function parseValue(value: unknown): Partial<typeof DEFAULTS> {
|
|
43
|
+
if (typeof value === "number") return { time: value }
|
|
44
|
+
if (typeof value === "string") {
|
|
45
|
+
const [t, touch, mouse] = value.split(":")
|
|
46
|
+
const out: Partial<typeof DEFAULTS> = {}
|
|
47
|
+
const num = Number(t)
|
|
48
|
+
if (Number.isFinite(num)) out.time = num
|
|
49
|
+
if (touch !== undefined) {
|
|
50
|
+
const n = Number(touch)
|
|
51
|
+
if (Number.isFinite(n)) out.touchSensitivity = n
|
|
52
|
+
}
|
|
53
|
+
if (mouse !== undefined) {
|
|
54
|
+
const n = Number(mouse)
|
|
55
|
+
if (Number.isFinite(n)) out.mouseSensitivity = n
|
|
56
|
+
}
|
|
57
|
+
return out
|
|
58
|
+
}
|
|
59
|
+
if (Array.isArray(value)) {
|
|
60
|
+
const out: Partial<typeof DEFAULTS> = {}
|
|
61
|
+
const t = Number(value[0])
|
|
62
|
+
if (Number.isFinite(t)) out.time = t
|
|
63
|
+
const touch = Number(value[1])
|
|
64
|
+
if (Number.isFinite(touch)) out.touchSensitivity = touch
|
|
65
|
+
const mouse = Number(value[2])
|
|
66
|
+
if (Number.isFinite(mouse)) out.mouseSensitivity = mouse
|
|
67
|
+
return out
|
|
68
|
+
}
|
|
69
|
+
if (value && typeof value === "object") {
|
|
70
|
+
const o = value as Record<string, unknown>
|
|
71
|
+
const out: Partial<typeof DEFAULTS> = {}
|
|
72
|
+
const t = Number(o.time ?? o.duration)
|
|
73
|
+
if (Number.isFinite(t)) out.time = t
|
|
74
|
+
const touch = Number(o.sensitivity ?? o.touchSensitivity)
|
|
75
|
+
if (Number.isFinite(touch)) out.touchSensitivity = touch
|
|
76
|
+
const mouse = Number(o.mouseSensitivity)
|
|
77
|
+
if (Number.isFinite(mouse)) out.mouseSensitivity = mouse
|
|
78
|
+
return out
|
|
79
|
+
}
|
|
80
|
+
return {}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const vTouchHold = {
|
|
84
|
+
mounted(el: HTMLElement, binding: { value?: unknown; modifiers?: Record<string, boolean> }) {
|
|
85
|
+
const handler = binding.value
|
|
86
|
+
if (typeof handler !== "function") {
|
|
87
|
+
console.warn("[v-touch-hold] value doit être une fonction (handler)")
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
const m = binding.modifiers ?? {}
|
|
91
|
+
const parsed = parseValue(binding.value)
|
|
92
|
+
const options: HoldOptions = {
|
|
93
|
+
handler: handler as TouchHoldHandler,
|
|
94
|
+
time: parsed.time ?? DEFAULTS.time,
|
|
95
|
+
touchSensitivity: parsed.touchSensitivity ?? DEFAULTS.touchSensitivity,
|
|
96
|
+
mouseSensitivity: parsed.mouseSensitivity ?? DEFAULTS.mouseSensitivity,
|
|
97
|
+
mouse: !!m.mouse || !!m.mouseCapture,
|
|
98
|
+
stop: !!m.stop,
|
|
99
|
+
prevent: !!m.prevent,
|
|
100
|
+
capture: !!m.capture || !!m.mouseCapture,
|
|
101
|
+
passive: !!m.passive,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Éviter scroll / context menu / sélection pendant l'appui
|
|
105
|
+
el.style.touchAction = "none"
|
|
106
|
+
el.style.userSelect = "none"
|
|
107
|
+
;(el as HTMLElement).style.webkitUserSelect = "none"
|
|
108
|
+
|
|
109
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
110
|
+
let pending = false
|
|
111
|
+
let consumed = false
|
|
112
|
+
let startX = 0
|
|
113
|
+
let startY = 0
|
|
114
|
+
let lastEvt: PointerEvent | null = null
|
|
115
|
+
let startTime = 0
|
|
116
|
+
|
|
117
|
+
const clearTimer = () => {
|
|
118
|
+
if (timer) clearTimeout(timer)
|
|
119
|
+
timer = undefined
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const cancel = () => {
|
|
123
|
+
clearTimer()
|
|
124
|
+
pending = false
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const finish = () => {
|
|
128
|
+
clearTimer()
|
|
129
|
+
if (!pending) return
|
|
130
|
+
pending = false
|
|
131
|
+
consumed = true
|
|
132
|
+
const duration = Math.round(performance.now() - startTime)
|
|
133
|
+
const evt = lastEvt
|
|
134
|
+
if (evt) {
|
|
135
|
+
options.handler({
|
|
136
|
+
evt,
|
|
137
|
+
position: { left: evt.clientX, top: evt.clientY },
|
|
138
|
+
duration: options.time || duration,
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
// Consomme la fin du geste : pas de click/touchend après un hold
|
|
142
|
+
const onUp = (e: Event) => {
|
|
143
|
+
if (options.prevent) e.preventDefault()
|
|
144
|
+
if (options.stop) e.stopPropagation()
|
|
145
|
+
window.removeEventListener("pointerup", onUp, true)
|
|
146
|
+
window.removeEventListener("pointercancel", onCancelAfter, true)
|
|
147
|
+
window.removeEventListener("click", onClick, true)
|
|
148
|
+
window.removeEventListener("touchend", onUp, true)
|
|
149
|
+
}
|
|
150
|
+
const onClick = (e: Event) => {
|
|
151
|
+
e.preventDefault()
|
|
152
|
+
e.stopPropagation()
|
|
153
|
+
window.removeEventListener("click", onClick, true)
|
|
154
|
+
window.removeEventListener("pointerup", onUp, true)
|
|
155
|
+
window.removeEventListener("touchend", onUp, true)
|
|
156
|
+
}
|
|
157
|
+
const onCancelAfter = () => {
|
|
158
|
+
window.removeEventListener("pointerup", onUp, true)
|
|
159
|
+
window.removeEventListener("pointercancel", onCancelAfter, true)
|
|
160
|
+
window.removeEventListener("click", onClick, true)
|
|
161
|
+
}
|
|
162
|
+
window.addEventListener("pointerup", onUp, true)
|
|
163
|
+
window.addEventListener("pointercancel", onCancelAfter, true)
|
|
164
|
+
window.addEventListener("touchend", onUp, true)
|
|
165
|
+
window.addEventListener("click", onClick, true)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const onPointerDown = (e: PointerEvent) => {
|
|
169
|
+
if (pending || consumed) return
|
|
170
|
+
if (e.pointerType === "mouse" && !options.mouse) return
|
|
171
|
+
if (options.prevent && !options.passive) e.preventDefault()
|
|
172
|
+
pending = true
|
|
173
|
+
startX = e.clientX
|
|
174
|
+
startY = e.clientY
|
|
175
|
+
startTime = performance.now()
|
|
176
|
+
lastEvt = e
|
|
177
|
+
const sensitivity = e.pointerType === "mouse" ? options.mouseSensitivity : options.touchSensitivity
|
|
178
|
+
timer = setTimeout(finish, options.time)
|
|
179
|
+
|
|
180
|
+
const onMove = (ev: PointerEvent) => {
|
|
181
|
+
if (!pending) return
|
|
182
|
+
if (Math.abs(ev.clientX - startX) > sensitivity || Math.abs(ev.clientY - startY) > sensitivity) {
|
|
183
|
+
cancel()
|
|
184
|
+
cleanup()
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const onUpEarly = () => cancel()
|
|
188
|
+
const cleanup = () => {
|
|
189
|
+
el.removeEventListener("pointermove", onMove)
|
|
190
|
+
el.removeEventListener("pointerup", onUpEarly)
|
|
191
|
+
el.removeEventListener("pointercancel", onUpEarly)
|
|
192
|
+
}
|
|
193
|
+
el.addEventListener("pointermove", onMove, { passive: options.passive })
|
|
194
|
+
el.addEventListener("pointerup", onUpEarly)
|
|
195
|
+
el.addEventListener("pointercancel", onUpEarly)
|
|
196
|
+
;(el as any).__qHoldCleanupEarly = cleanup
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const onContext = (e: Event) => {
|
|
200
|
+
if (pending || consumed) e.preventDefault()
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const capture = options.capture
|
|
204
|
+
el.addEventListener("pointerdown", onPointerDown, { capture, passive: options.passive })
|
|
205
|
+
el.addEventListener("contextmenu", onContext, { capture })
|
|
206
|
+
|
|
207
|
+
;(el as QElement).__qTouchHoldCleanup = () => {
|
|
208
|
+
el.removeEventListener("pointerdown", onPointerDown, { capture } as any)
|
|
209
|
+
el.removeEventListener("contextmenu", onContext, { capture } as any)
|
|
210
|
+
;(el as any).__qHoldCleanupEarly?.()
|
|
211
|
+
clearTimer()
|
|
212
|
+
el.style.touchAction = ""
|
|
213
|
+
el.style.userSelect = ""
|
|
214
|
+
el.style.webkitUserSelect = ""
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
unmounted(el: HTMLElement) {
|
|
218
|
+
;(el as QElement).__qTouchHoldCleanup?.()
|
|
219
|
+
delete (el as QElement).__qTouchHoldCleanup
|
|
220
|
+
},
|
|
221
|
+
}
|