@dnax/ui 0.0.48 → 0.0.50

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.
Files changed (48) hide show
  1. package/components/QBottomSheet.vue +172 -6
  2. package/components/QChart.vue +1 -0
  3. package/components/QDatePicker.vue +1 -1
  4. package/components/QImagePicker.vue +28 -1
  5. package/components/QInputChat.vue +859 -0
  6. package/components/QInputCurrency.vue +319 -0
  7. package/components/QItemLabel.vue +99 -0
  8. package/components/QMap.vue +410 -0
  9. package/components/QNumericKeyboard.vue +304 -0
  10. package/components/QPage.vue +26 -6
  11. package/components/QPageContainer.vue +32 -0
  12. package/components/QPopupProxy.vue +378 -0
  13. package/components/QQrcode.vue +132 -0
  14. package/components/QSelect.vue +12 -0
  15. package/components/QSpreadsheet.vue +395 -69
  16. package/components/QTime.vue +845 -0
  17. package/components/QTimeline.vue +111 -0
  18. package/components/QTimelineEntry.vue +271 -0
  19. package/index.ts +11 -1
  20. package/lib/bottomSheet.test.ts +121 -0
  21. package/lib/bottomSheet.ts +115 -0
  22. package/lib/color.ts +13 -0
  23. package/lib/currency.test.ts +193 -0
  24. package/lib/currency.ts +301 -0
  25. package/lib/fixedLayout.ts +12 -11
  26. package/lib/icons.ts +3 -20
  27. package/lib/map.test.ts +365 -0
  28. package/lib/map.ts +438 -0
  29. package/lib/mapEngine.ts +158 -0
  30. package/lib/mapLeaflet.ts +142 -0
  31. package/lib/mapMaplibre.ts +106 -0
  32. package/lib/mapMaptiler.ts +93 -0
  33. package/lib/numericKeyboard.test.ts +179 -0
  34. package/lib/numericKeyboard.ts +166 -0
  35. package/lib/pagePadding.test.ts +37 -0
  36. package/lib/pagePadding.ts +32 -0
  37. package/lib/qrcode.test.ts +103 -0
  38. package/lib/qrcode.ts +125 -0
  39. package/lib/spreadsheetChanges.test.ts +230 -0
  40. package/lib/spreadsheetChanges.ts +331 -0
  41. package/package.json +5 -17
  42. package/shims.d.ts +19 -0
  43. package/styles/main.css +689 -884
  44. package/components/QTiptap.vue +0 -996
  45. package/components/internal/QTiptapTableView.vue +0 -123
  46. package/components/internal/QTiptapTaskItemView.vue +0 -49
  47. package/lib/tiptap-table.ts +0 -19
  48. package/lib/tiptap-task-list.ts +0 -16
@@ -1,996 +0,0 @@
1
- <script setup lang="ts">
2
- // QTiptap — éditeur de texte riche basé sur Tiptap v3 (https://tiptap.dev).
3
- // API Quasar : v-model = HTML (<q-tiptap v-model="html" />).
4
- // Barre d'outils intégrée : undo/redo, paragraphe + titres H1-H3, marques
5
- // (gras/italique/souligné/barré/code), lien, listes, citation, bloc de code,
6
- // séparateur et nettoyage de la mise en forme.
7
- // SSR-safe : l'éditeur ProseMirror n'est créé qu'au montage (côté client).
8
- import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue"
9
- import { Editor, EditorContent } from "@tiptap/vue-3"
10
- import { BubbleMenu } from "@tiptap/vue-3/menus"
11
- import { DragHandle } from "@tiptap/extension-drag-handle-vue-3"
12
- import { TableOfContents } from "@tiptap/extension-table-of-contents"
13
- import { QTipTapTable, TableRow, TableHeader, TableCell } from "../lib/tiptap-table"
14
- import StarterKit from "@tiptap/starter-kit"
15
- // v3 : Color et TextStyle vivent dans @tiptap/extension-text-style
16
- // (l'extension @tiptap/extension-color n'est qu'un alias de ré-export)
17
- import { Color, TextStyle, FontSize } from "@tiptap/extension-text-style"
18
- import Image from "@tiptap/extension-image"
19
- import TextAlign from "@tiptap/extension-text-align"
20
- import Mention from "@tiptap/extension-mention"
21
- import { TaskList, QTipTapTaskItem } from "../lib/tiptap-task-list"
22
- import { Icon } from "@iconify/vue"
23
- import QDialog from "./QDialog.vue"
24
- import QDialogHeader from "./QDialogHeader.vue"
25
- import QDialogFooter from "./QDialogFooter.vue"
26
- import QBtn from "./QBtn.vue"
27
- import QInput from "./QInput.vue"
28
- import QSelect from "./QSelect.vue"
29
- import { icons } from "../lib/icons"
30
-
31
- // Drag handle : config constante HORS du composant — évite la réinitialisation
32
- // de la plugin à chaque rendu (préconisation doc Tiptap Vue). threshold
33
- // négatif : la poignée vise facilement les blocs imbriqués (listes…).
34
- const DRAG_HANDLE_NESTED = { edgeDetection: { threshold: -16 } }
35
-
36
- // — Table des matières : ancre de chaque titre (remontée via @update:toc) —
37
- interface QTiptapTocItem {
38
- id: string
39
- textContent: string
40
- level: number
41
- originalLevel: number
42
- itemIndex: number
43
- pos: number
44
- isActive: boolean
45
- isScrolledOver: boolean
46
- }
47
-
48
- interface Props {
49
- /** HTML édité (v-model) */
50
- modelValue?: string
51
- /** Affiche la barre d'outils (défaut : true) */
52
- toolbar?: boolean
53
- /** Texte affiché tant que le document est vide */
54
- placeholder?: string
55
- /** Items de mention (@) : { label, value } — filtre au fil de la frappe */
56
- mentions?: { label: string; value: string }[]
57
- /** Variante « filled » (fond grisé, sans bordure pleine) — défaut : contenu blanc bordé */
58
- filled?: boolean
59
- /** Hauteur minimale de la zone d'édition (défaut : 180px) */
60
- minHeight?: string
61
- /** Padding de la zone d'écriture (valeur CSS — défaut : 5% sur les 4 côtés) */
62
- padding?: string
63
- /** Compact (toolbar + zone réduites) */
64
- dense?: boolean
65
- /** Coins carrés (défaut : arrondi --q-radius) */
66
- square?: boolean
67
- /** Désactive entièrement (édition coupée + boutons grisés) */
68
- disable?: boolean
69
- /** Lecture seule : texte visible mais non éditable */
70
- readonly?: boolean
71
- }
72
-
73
- const props = withDefaults(defineProps<Props>(), {
74
- modelValue: "",
75
- toolbar: true,
76
- placeholder: "Write something…",
77
- mentions: () => [],
78
- filled: false,
79
- minHeight: "180px",
80
- padding: "5%",
81
- dense: false,
82
- square: false,
83
- disable: false,
84
- readonly: false,
85
- })
86
-
87
- const emit = defineEmits<{
88
- "update:modelValue": [html: string]
89
- focus: []
90
- blur: []
91
- ready: [editor: Editor]
92
- /** Nouvelle table des matières (titres du document) — prête à afficher */
93
- "update:toc": [items: QTiptapTocItem[]]
94
- }>()
95
-
96
- // — Instance de l'éditeur (créée au montage uniquement → SSR-safe) —
97
- const editor = ref<Editor>()
98
- /** Dernière table des matières émise (pour usage interne / debug) */
99
- const tocAnchors = ref<QTiptapTocItem[]>([])
100
- /** Incrémenté à chaque transaction/sélection → recalcule l'état de la barre */
101
- const tick = ref(0)
102
- /** Dernière couleur choisie — rappelée quand la sélection n'a pas de couleur */
103
- const picked = ref("#1d1d1d")
104
-
105
- const isEditable = () => !props.disable && !props.readonly
106
-
107
- // Style de la zone d'écriture : min-height + padding exposé en variable CSS
108
- // (consommée par .q-tiptap__editor .ProseMirror dans styles/main.css)
109
- const editorStyle = computed<Record<string, string>>(() => ({
110
- minHeight: props.minHeight,
111
- "--q-tiptap-content-padding": props.padding,
112
- }))
113
-
114
- onMounted(() => {
115
- const ed = new Editor({
116
- content: props.modelValue || "<p></p>",
117
- extensions: [
118
- TextStyle,
119
- FontSize,
120
- Color,
121
- Image,
122
- TextAlign.configure({
123
- types: ["heading", "paragraph"],
124
- alignments: ["left", "center", "right"],
125
- }),
126
- TaskList,
127
- QTipTapTaskItem,
128
- QTipTapTable,
129
- TableRow,
130
- TableHeader,
131
- TableCell,
132
- Mention.configure({
133
- suggestion: {
134
- char: "@",
135
- startOfLine: false,
136
- allow: () => (props.mentions?.length ?? 0) > 0,
137
- items: ({ query }) => mentionItems(query),
138
- command: ({ editor, range, props: item }: any) => {
139
- editor.chain().focus().insertContentAt(range, [
140
- { type: "mention", attrs: { id: item.id, label: item.label } },
141
- { type: "text", text: " " },
142
- ]).run()
143
- },
144
- render: () => mentionPopupRender(),
145
- },
146
- }),
147
- TableOfContents.configure({
148
- onUpdate: (anchors: any[]) => {
149
- tocAnchors.value = anchors.map((a) => ({
150
- id: a.id,
151
- textContent: a.textContent,
152
- level: a.level,
153
- originalLevel: a.originalLevel,
154
- itemIndex: a.itemIndex,
155
- pos: a.pos,
156
- isActive: a.isActive,
157
- isScrolledOver: a.isScrolledOver,
158
- }))
159
- emit("update:toc", tocAnchors.value)
160
- },
161
- }),
162
- StarterKit.configure({
163
- // Titres limités à H1-H3 dans la barre (les niveaux 4-6 restent
164
- // lisibles via HTML mais pas proposés) — Link : pas d'ouverture au clic
165
- heading: { levels: [1, 2, 3] },
166
- link: { openOnClick: false },
167
- }),
168
- ],
169
- editable: isEditable(),
170
- onUpdate: ({ editor: instance }) => emit("update:modelValue", instance.getHTML()),
171
- onTransaction: () => { tick.value++ },
172
- onSelectionUpdate: () => { tick.value++ },
173
- onFocus: () => emit("focus"),
174
- onBlur: () => emit("blur"),
175
- })
176
- editor.value = ed
177
- emit("ready", ed)
178
- })
179
-
180
- onBeforeUnmount(() => {
181
- editor.value?.destroy()
182
- editor.value = undefined
183
- })
184
-
185
- // v-model externe → contenu (sans re-émettre ni perdre le curseur) :
186
- // on ne réécrit le document que si le HTML a réellement changé.
187
- watch(() => props.modelValue, (html) => {
188
- const ed = editor.value
189
- if (!ed || html === undefined) return
190
- const current = ed.getHTML()
191
- const next = html || "<p></p>"
192
- if (current === next) return
193
- // Cas « document vidé » : l'éditeur normalise en <p></p> — rien à réécrire
194
- if (current === "<p></p>" && (html === "" || html === "<p></p>")) return
195
- ed.commands.setContent(next, { emitUpdate: false })
196
- })
197
-
198
- // disable / readonly → editable
199
- watch([() => props.disable, () => props.readonly], () => {
200
- editor.value?.setEditable(isEditable())
201
- })
202
-
203
- const showPlaceholder = computed(() => {
204
- void tick.value
205
- return !props.disable && !!editor.value?.isEmpty
206
- })
207
-
208
- // — Barre d'outils —
209
- interface ToolSpec {
210
- label: string
211
- icon: string
212
- active?: (ed: Editor) => boolean
213
- disabled?: (ed: Editor) => boolean
214
- run: (ed: Editor) => void
215
- /** true → sélecteur de couleur natif (input type=color) à la place du bouton */
216
- kind?: "color"
217
- }
218
-
219
- interface ToolView {
220
- label: string
221
- icon: string
222
- active: boolean
223
- disabled: boolean
224
- run: () => void
225
- kind?: "color"
226
- /** Couleur courante du texte sélectionné ("" si aucune) — barre sous l'icône */
227
- swatch?: string
228
- }
229
-
230
- // — Couleur de texte : palette popover (un input type=color natif ne permet
231
- // pas de DÉSÉLECTIONNER — pas de valeur « aucune ») —
232
- const PRESET_COLORS = [
233
- "#1d1d1d", "#6b7280", "#ffffff", "#ef4444", "#f97316", "#f59e0b",
234
- "#eab308", "#22c55e", "#10b981", "#06b6d4", "#3b82f6", "#6366f1",
235
- "#8b5cf6", "#ec4899",
236
- ] as const
237
-
238
- const colorOpen = ref(false)
239
- const paletteStyle = ref<Record<string, string>>({})
240
-
241
- /** Couleur du texte sélectionné ("" si aucune) — recalculée à chaque transaction */
242
- const paletteCurrent = computed(() => {
243
- void tick.value
244
- const ed = editor.value
245
- if (!ed) return ""
246
- const color = ed.getAttributes("textStyle").color
247
- return typeof color === "string" ? color : ""
248
- })
249
-
250
- const togglePalette = (event: MouseEvent) => {
251
- if (colorOpen.value) {
252
- colorOpen.value = false
253
- return
254
- }
255
- const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
256
- const width = 224
257
- const left = Math.min(Math.max(rect.left, 8), window.innerWidth - width - 8)
258
- paletteStyle.value = { top: `${rect.bottom + 6}px`, left: `${left}px` }
259
- colorOpen.value = true
260
- }
261
-
262
- const applyColor = (hex: string) => {
263
- const ed = editor.value
264
- if (!ed) return
265
- picked.value = hex
266
- colorOpen.value = false
267
- ed.chain().focus().setColor(hex).run()
268
- }
269
-
270
- const removeColor = () => {
271
- const ed = editor.value
272
- if (!ed) return
273
- colorOpen.value = false
274
- ed.chain().focus().unsetColor().run()
275
- }
276
-
277
- const onColorInput = (event: Event) => {
278
- const input = event.target as HTMLInputElement
279
- if (input.value) applyColor(input.value)
280
- }
281
-
282
- // Fermeture de la palette : clic à l'extérieur, Échap, blur de la fenêtre,
283
- // resize/scroll (la palette est positionnée en fixed sous le bouton)
284
- watch(colorOpen, (open) => {
285
- if (!open) return
286
- const onDown = (e: MouseEvent) => {
287
- const target = e.target as Node
288
- if (!document.querySelector(".q-tiptap__palette")?.contains(target)) {
289
- colorOpen.value = false
290
- }
291
- }
292
- const onKey = (e: KeyboardEvent) => {
293
- if (e.key === "Escape") colorOpen.value = false
294
- }
295
- const close = () => { colorOpen.value = false }
296
- // Phase de CAPTURE : un `@mousedown.stop` parent bloque sinon l'événement avant
297
- // `document` → palette de couleurs jamais fermée au clic extérieur.
298
- document.addEventListener("mousedown", onDown, true)
299
- document.addEventListener("keydown", onKey)
300
- window.addEventListener("blur", close)
301
- window.addEventListener("resize", close)
302
- return () => {
303
- document.removeEventListener("mousedown", onDown, true)
304
- document.removeEventListener("keydown", onKey)
305
- window.removeEventListener("blur", close)
306
- window.removeEventListener("resize", close)
307
- }
308
- })
309
-
310
- // — Image / lien : saisie par URL dans un q-dialog (pas de window.prompt) —
311
- const dialogOpen = ref(false)
312
- const dialogMode = ref<"image" | "link">("image")
313
- const dialogUrl = ref("")
314
- const dialogAlt = ref("")
315
- /** true quand l'image sélectionnée doit être remplacée (pas dupliquée) */
316
- const replacingImage = ref(false)
317
-
318
- const dialogTitle = computed(() =>
319
- dialogMode.value === "image" ? "Insert image" : "Insert link",
320
- )
321
- const dialogUrlLabel = computed(() =>
322
- dialogMode.value === "image" ? "Image URL" : "Link URL",
323
- )
324
- const dialogUrlPlaceholder = computed(() =>
325
- dialogMode.value === "image"
326
- ? "https://example.com/image.png"
327
- : "https://example.com/page",
328
- )
329
- const dialogSubmitLabel = computed(() =>
330
- dialogMode.value === "image" ? "Insert image" : "Insert link",
331
- )
332
-
333
- const openImageDialog = (ed: Editor) => {
334
- replacingImage.value = ed.isActive("image")
335
- const attrs = ed.getAttributes("image")
336
- dialogUrl.value = typeof attrs.src === "string" ? (attrs.src as string) : ""
337
- dialogAlt.value = typeof attrs.alt === "string" ? (attrs.alt as string) : ""
338
- dialogMode.value = "image"
339
- dialogOpen.value = true
340
- }
341
-
342
- const openLinkDialog = (ed: Editor) => {
343
- if (ed.state.selection.empty) return
344
- const href = ed.getAttributes("link").href
345
- dialogUrl.value = typeof href === "string" ? (href as string) : ""
346
- dialogMode.value = "link"
347
- dialogOpen.value = true
348
- }
349
-
350
- const submitDialog = () => {
351
- const ed = editor.value
352
- const value = dialogUrl.value.trim()
353
- if (!ed || !value) return
354
- dialogOpen.value = false
355
- if (dialogMode.value === "image") {
356
- const alt = dialogAlt.value.trim()
357
- const chain = ed.chain().focus()
358
- if (replacingImage.value) {
359
- const attrs: Record<string, unknown> = { src: value }
360
- if (alt) attrs.alt = alt
361
- chain.updateAttributes("image", attrs)
362
- }
363
- else {
364
- const opts: { src: string; alt?: string } = { src: value }
365
- if (alt) opts.alt = alt
366
- chain.setImage(opts)
367
- }
368
- chain.run()
369
- }
370
- else {
371
- ed.chain().focus().extendMarkRange("link").setLink({ href: value }).run()
372
- }
373
- }
374
-
375
-
376
- // — Mentions (@) : filtre des items { label, value } passés en prop —
377
- interface MentionPopupItem {
378
- id: string
379
- label: string
380
- }
381
-
382
- const mentionItems = (query: string): MentionPopupItem[] => {
383
- const q = query.trim().toLowerCase()
384
- return (props.mentions ?? [])
385
- .filter(
386
- (item) =>
387
- !q ||
388
- item.label.toLowerCase().includes(q) ||
389
- item.value.toLowerCase().includes(q),
390
- )
391
- .map((item) => ({ id: item.value, label: item.label }))
392
- }
393
-
394
- // Popup de suggestion (DOM stylé dnax.ui) montée via props.mount → la plugin
395
- // l'ancre au curseur, la repositionne (scroll/resize) et fournit unmount().
396
- const mentionPopupRender = () => {
397
- let popup: HTMLElement | null = null
398
- let list: HTMLElement | null = null
399
- let unmount: (() => void) | null = null
400
- let current: { items: MentionPopupItem[]; command: (item: MentionPopupItem) => void } | null = null
401
- let index = 0
402
-
403
- const rows = () =>
404
- Array.from(list?.querySelectorAll<HTMLElement>(".q-tiptap__mention-item") ?? [])
405
-
406
- const highlight = () => {
407
- rows().forEach((row, i) =>
408
- row.classList.toggle("q-tiptap__mention-item--active", i === index),
409
- )
410
- }
411
-
412
- const selectItem = () => {
413
- const item = current?.items[index]
414
- if (item && current) current.command(item)
415
- }
416
-
417
- const renderList = (props: {
418
- items: MentionPopupItem[]
419
- command: (item: MentionPopupItem) => void
420
- mount: (el: HTMLElement) => () => void
421
- }) => {
422
- if (!popup) {
423
- popup = document.createElement("div")
424
- popup.className = "q-tiptap__mention"
425
- list = document.createElement("div")
426
- list.className = "q-tiptap__mention-list"
427
- popup.appendChild(list)
428
- unmount = props.mount(popup)
429
- }
430
- if (!list) return
431
- list.innerHTML = ""
432
- const items = props.items ?? []
433
- if (!items.length) {
434
- const empty = document.createElement("div")
435
- empty.className = "q-tiptap__mention-empty"
436
- empty.textContent = "No results"
437
- list.appendChild(empty)
438
- return
439
- }
440
- index = Math.min(index, items.length - 1)
441
- items.forEach((item, i) => {
442
- const row = document.createElement("button")
443
- row.type = "button"
444
- row.className = "q-tiptap__mention-item"
445
- row.textContent = `@${item.label}`
446
- row.addEventListener("mousedown", (e) => e.preventDefault())
447
- row.addEventListener("mouseenter", () => {
448
- index = i
449
- highlight()
450
- })
451
- row.addEventListener("click", () => {
452
- index = i
453
- selectItem()
454
- })
455
- list!.appendChild(row)
456
- })
457
- highlight()
458
- }
459
-
460
- return {
461
- onStart: (props: any) => {
462
- current = props
463
- index = 0
464
- renderList(props)
465
- },
466
- onUpdate: (props: any) => {
467
- current = props
468
- renderList(props)
469
- },
470
- onExit: () => {
471
- unmount?.()
472
- popup = null
473
- list = null
474
- unmount = null
475
- current = null
476
- },
477
- onKeyDown: ({ event }: { event: KeyboardEvent }) => {
478
- const count = current?.items.length ?? 0
479
- if (!count) return false
480
- if (event.key === "ArrowDown") {
481
- event.preventDefault()
482
- index = (index + 1) % count
483
- highlight()
484
- return true
485
- }
486
- if (event.key === "ArrowUp") {
487
- event.preventDefault()
488
- index = (index - 1 + count) % count
489
- highlight()
490
- return true
491
- }
492
- if (event.key === "Enter" || event.key === "Tab") {
493
- event.preventDefault()
494
- selectItem()
495
- return true
496
- }
497
- return false
498
- },
499
- }
500
- }
501
-
502
- const HEADING_LEVELS = [1, 2, 3] as const
503
- const ALIGNMENTS = ["left", "center", "right"] as const
504
- // — Taille de police (sélecteur toolbar) —
505
- const FONT_SIZES = [12, 14, 16, 18, 20, 24, 28, 32, 40] as const
506
-
507
- const FONT_SIZE_OPTIONS = [
508
- { label: "Default", value: "" },
509
- ...FONT_SIZES.map((size) => ({ label: `${size} px`, value: String(size) })),
510
- ]
511
-
512
- const currentFontSize = computed(() => {
513
- void tick.value
514
- const ed = editor.value
515
- if (!ed) return ""
516
- const size = (ed.getAttributes("textStyle").fontSize as string | undefined) ?? ""
517
- const parsed = Number.parseInt(size, 10)
518
- return Number.isFinite(parsed) ? String(parsed) : ""
519
- })
520
-
521
- const onFontSizeUpdate = (value: unknown) => {
522
- const ed = editor.value
523
- if (!ed) return
524
- const size = String(value ?? "")
525
- if (!size) ed.chain().focus().unsetFontSize().run()
526
- else ed.chain().focus().setFontSize(`${size}px`).run()
527
- }
528
-
529
- const toolGroups = computed<{ id: string; items: ToolView[] }[]>(() => {
530
- void tick.value
531
- const ed = editor.value
532
- if (!ed) return []
533
-
534
- const resolve = (spec: ToolSpec): ToolView => {
535
- if (spec.kind === "color") {
536
- const current =
537
- typeof ed.getAttributes("textStyle").color === "string"
538
- ? (ed.getAttributes("textStyle").color as string)
539
- : ""
540
- return {
541
- label: spec.label,
542
- icon: spec.icon,
543
- kind: "color",
544
- active: !!current,
545
- disabled: !ed.isEditable,
546
- swatch: current || "transparent",
547
- run: () => {},
548
- }
549
- }
550
- return {
551
- label: spec.label,
552
- icon: spec.icon,
553
- active: spec.active?.(ed) ?? false,
554
- disabled: spec.disabled ? spec.disabled(ed) : !ed.isEditable,
555
- run: () => spec.run(ed),
556
- }
557
- }
558
-
559
- const groups: { id: string; tools: ToolSpec[] }[] = [
560
- {
561
- id: "history",
562
- tools: [
563
- {
564
- label: "Undo",
565
- icon: icons.undo2,
566
- disabled: (e) => !e.can().chain().undo().run(),
567
- run: (e) => { e.chain().focus().undo().run() },
568
- },
569
- {
570
- label: "Redo",
571
- icon: icons.redo2,
572
- disabled: (e) => !e.can().chain().redo().run(),
573
- run: (e) => { e.chain().focus().redo().run() },
574
- },
575
- ],
576
- },
577
- {
578
- id: "blocks",
579
- tools: [
580
- {
581
- label: "Paragraph",
582
- icon: icons.pilcrow,
583
- active: (e) => e.isActive("paragraph"),
584
- run: (e) => { e.chain().focus().setParagraph().run() },
585
- },
586
- ...HEADING_LEVELS.map((level) => ({
587
- label: `Heading ${level}`,
588
- icon: [icons.heading1, icons.heading2, icons.heading3][level - 1]!,
589
- active: (e: Editor) => e.isActive("heading", { level }),
590
- run: (e: Editor) => { e.chain().focus().toggleHeading({ level }).run() },
591
- })),
592
- ],
593
- },
594
- {
595
- id: "align",
596
- tools: ALIGNMENTS.map((align) => {
597
- const iconsByAlign = [icons.alignLeft, icons.alignCenter, icons.alignRight] as const
598
- return {
599
- label: `Align ${align}`,
600
- icon: iconsByAlign[ALIGNMENTS.indexOf(align)]!,
601
- // « left » est l'alignement implicite (aucun attribut posé)
602
- active: (e: Editor) => {
603
- const node = e.isActive("heading") ? "heading" : "paragraph"
604
- const textAlign = e.getAttributes(node).textAlign
605
- return (typeof textAlign === "string" ? textAlign : "left") === align
606
- },
607
- run: (e: Editor) => { e.chain().focus().setTextAlign(align).run() },
608
- }
609
- }),
610
- },
611
- {
612
- id: "font-size",
613
- tools: [],
614
- },
615
- {
616
- id: "marks",
617
- tools: [
618
- {
619
- label: "Bold",
620
- icon: icons.bold,
621
- active: (e) => e.isActive("bold"),
622
- run: (e) => { e.chain().focus().toggleBold().run() },
623
- },
624
- {
625
- label: "Italic",
626
- icon: icons.italic,
627
- active: (e) => e.isActive("italic"),
628
- run: (e) => { e.chain().focus().toggleItalic().run() },
629
- },
630
- {
631
- label: "Underline",
632
- icon: icons.underline,
633
- active: (e) => e.isActive("underline"),
634
- run: (e) => { e.chain().focus().toggleUnderline().run() },
635
- },
636
- {
637
- label: "Strikethrough",
638
- icon: icons.strikethrough,
639
- active: (e) => e.isActive("strike"),
640
- run: (e) => { e.chain().focus().toggleStrike().run() },
641
- },
642
- {
643
- label: "Inline code",
644
- icon: icons.code,
645
- active: (e) => e.isActive("code"),
646
- run: (e) => { e.chain().focus().toggleCode().run() },
647
- },
648
- {
649
- label: "Link",
650
- icon: icons.link,
651
- active: (e) => e.isActive("link"),
652
- disabled: (e) => !e.isEditable || e.state.selection.empty,
653
- run: openLinkDialog,
654
- },
655
- {
656
- label: "Remove link",
657
- icon: icons.unlink,
658
- active: (e) => e.isActive("link"),
659
- run: (e) => { e.chain().focus().extendMarkRange("link").unsetLink().run() },
660
- },
661
- {
662
- label: "Text color",
663
- icon: icons.palette,
664
- kind: "color",
665
- run: () => {},
666
- },
667
- {
668
- label: "Remove color",
669
- icon: icons.eraser,
670
- active: (e) => typeof e.getAttributes("textStyle").color === "string",
671
- run: (e) => { e.chain().focus().unsetColor().run() },
672
- },
673
- ],
674
- },
675
- {
676
- id: "blocks-list",
677
- tools: [
678
- {
679
- label: "Bullet list",
680
- icon: icons.list,
681
- active: (e) => e.isActive("bulletList"),
682
- run: (e) => { e.chain().focus().toggleBulletList().run() },
683
- },
684
- {
685
- label: "Ordered list",
686
- icon: icons.listOrdered,
687
- active: (e) => e.isActive("orderedList"),
688
- run: (e) => { e.chain().focus().toggleOrderedList().run() },
689
- },
690
- {
691
- label: "Task list",
692
- icon: icons.listChecks,
693
- active: (e) => e.isActive("taskList"),
694
- run: (e) => { e.chain().focus().toggleTaskList().run() },
695
- },
696
- {
697
- label: "Blockquote",
698
- icon: icons.quote,
699
- active: (e) => e.isActive("blockquote"),
700
- run: (e) => { e.chain().focus().toggleBlockquote().run() },
701
- },
702
- {
703
- label: "Code block",
704
- icon: icons.codeXml,
705
- active: (e) => e.isActive("codeBlock"),
706
- run: (e) => { e.chain().focus().toggleCodeBlock().run() },
707
- },
708
- {
709
- label: "Image URL",
710
- icon: icons.imagePlus,
711
- active: (e) => e.isActive("image"),
712
- run: openImageDialog,
713
- },
714
- {
715
- label: "Horizontal rule",
716
- icon: icons.minus,
717
- run: (e) => { e.chain().focus().setHorizontalRule().run() },
718
- },
719
- ],
720
- },
721
- {
722
- id: "clear",
723
- tools: [
724
- {
725
- label: "Clear formatting",
726
- icon: icons.removeFormatting,
727
- run: (e) => { e.chain().focus().clearNodes().unsetAllMarks().run() },
728
- },
729
- ],
730
- },
731
- ]
732
-
733
- return groups.map((g) => ({ id: g.id, items: g.tools.map(resolve) }))
734
- })
735
-
736
- // — Bubble menu (barre contextuelle sur sélection de texte) —
737
- const bubbleShouldShow = (props: any): boolean => {
738
- const ed = props.editor as Editor | undefined
739
- if (!ed || !ed.isEditable) return false
740
- if (ed.isActive("codeBlock") || ed.isActive("image")) return false
741
- return !ed.state.selection.empty
742
- }
743
-
744
- const bubbleTools = computed(() => {
745
- void tick.value
746
- const ed = editor.value
747
- if (!ed) return []
748
- const tool = (
749
- label: string,
750
- icon: string,
751
- active: (e: Editor) => boolean,
752
- run: (e: Editor) => void,
753
- ) => ({ label, icon, active: active(ed), run: () => run(ed) })
754
- return [
755
- tool("Bold", icons.bold, (e) => e.isActive("bold"), (e) => { e.chain().focus().toggleBold().run() }),
756
- tool("Italic", icons.italic, (e) => e.isActive("italic"), (e) => { e.chain().focus().toggleItalic().run() }),
757
- tool("Underline", icons.underline, (e) => e.isActive("underline"), (e) => { e.chain().focus().toggleUnderline().run() }),
758
- tool("Strikethrough", icons.strikethrough, (e) => e.isActive("strike"), (e) => { e.chain().focus().toggleStrike().run() }),
759
- tool("Inline code", icons.code, (e) => e.isActive("code"), (e) => { e.chain().focus().toggleCode().run() }),
760
- tool("Link", icons.link, (e) => e.isActive("link"), openLinkDialog),
761
- tool("Remove link", icons.unlink, (e) => e.isActive("link"), (e) => { e.chain().focus().extendMarkRange("link").unsetLink().run() }),
762
- tool("Remove color", icons.eraser, () => !!paletteCurrent.value, (e) => { e.chain().focus().unsetColor().run() }),
763
- tool("Clear formatting", icons.removeFormatting, () => false, (e) => { e.chain().focus().clearNodes().unsetAllMarks().run() }),
764
- ]
765
- })
766
-
767
- // Focus programmatique (defineExpose)
768
- const focusEditor = () => editor.value?.commands.focus()
769
-
770
- // — Table des matières : scroll interne + accès aux ancres —
771
- /** Scrolle l'éditeur (et son conteneur) jusqu'au titre dont l'id TOC est donné */
772
- const scrollToHeading = (id: string) => {
773
- const ed = editor.value
774
- if (!ed) return
775
- const root = ed.view.dom as HTMLElement
776
- const byAttr = root.querySelector<HTMLElement>(`[data-toc-id="${id}"], [id="${id}"]`)
777
- const item = tocAnchors.value.find((a) => a.id === id)
778
- const byPos =
779
- typeof item?.pos === "number"
780
- ? (ed.view.nodeDOM(item.pos) as HTMLElement | null)
781
- : null
782
- ;(byAttr ?? byPos)?.scrollIntoView({ behavior: "smooth", block: "start" })
783
- }
784
-
785
- /** Dernière table des matières connue (copie sérialisable) */
786
- const getToc = () => tocAnchors.value.map((item) => ({ ...item }))
787
-
788
- defineExpose({
789
- focus: focusEditor,
790
- scrollToHeading,
791
- getToc,
792
- })
793
- </script>
794
-
795
- <template>
796
- <div
797
- class="q-tiptap"
798
- :class="{
799
- 'q-tiptap--filled': filled,
800
- 'q-tiptap--dense': dense,
801
- 'q-tiptap--square': square,
802
- 'q-tiptap--disable': disable,
803
- }"
804
- >
805
- <div v-if="toolbar && editor" class="q-tiptap__toolbar" role="toolbar" aria-label="Formatting">
806
- <div v-for="group in toolGroups" :key="group.id" class="q-tiptap__group">
807
- <div v-if="group.id === 'font-size'" class="q-tiptap__size" title="Font size">
808
- <q-select
809
- :model-value="currentFontSize"
810
- :options="FONT_SIZE_OPTIONS"
811
- emit-value
812
- option-value="value"
813
- option-label="label"
814
- dense
815
- outlined
816
- :disable="!editor.isEditable"
817
- aria-label="Font size"
818
- class="q-tiptap__font-size"
819
- :inline-options="{ class: 'q-tiptap__font-size-popup', width: '100px' }"
820
- @update:model-value="onFontSizeUpdate"
821
- />
822
- </div>
823
- <template v-if="group.id !== 'font-size'">
824
- <template v-for="tool in group.items" :key="tool.label">
825
- <button
826
- v-if="tool.kind !== 'color'"
827
- type="button"
828
- class="q-tiptap__tool"
829
- :class="{ 'q-tiptap__tool--active': tool.active }"
830
- :disabled="tool.disabled"
831
- :aria-label="tool.label"
832
- :aria-pressed="tool.active"
833
- :title="tool.label"
834
- @mousedown.prevent
835
- @click="tool.run"
836
- >
837
- <Icon :icon="tool.icon" aria-hidden="true" />
838
- </button>
839
- <button
840
- v-else-if="tool.kind === 'color'"
841
- type="button"
842
- class="q-tiptap__tool q-tiptap__tool--color"
843
- :class="{
844
- 'q-tiptap__tool--active': tool.active,
845
- 'q-tiptap__tool--disabled': tool.disabled,
846
- }"
847
- :disabled="tool.disabled"
848
- :aria-label="tool.label"
849
- :aria-haspopup="true"
850
- :aria-expanded="colorOpen ? 'true' : 'false'"
851
- :title="tool.label"
852
- @mousedown.prevent
853
- @click="togglePalette($event)"
854
- >
855
- <Icon :icon="tool.icon" aria-hidden="true" />
856
- <span class="q-tiptap__color-bar" :style="{ backgroundColor: tool.swatch }" />
857
- </button>
858
- </template>
859
- </template>
860
- </div>
861
- </div>
862
-
863
- <div class="q-tiptap__body">
864
- <span v-if="showPlaceholder" class="q-tiptap__placeholder" aria-hidden="true">
865
- {{ placeholder }}
866
- </span>
867
- <div class="q-tiptap__editor" :style="editorStyle">
868
- <editor-content :editor="editor" />
869
- </div>
870
- </div>
871
- </div>
872
-
873
- <!-- Drag handle : poignée pour déplacer les blocs (survol bord gauche) -->
874
- <DragHandle
875
- v-if="editor && !disable && !readonly"
876
- :editor="editor"
877
- :nested="DRAG_HANDLE_NESTED"
878
- plugin-key="qTipTapDragHandle"
879
- >
880
- <span class="q-tiptap__drag-handle">
881
- <Icon :icon="icons.gripVertical" aria-hidden="true" />
882
- </span>
883
- </DragHandle>
884
-
885
- <!-- Bubble menu : barre de formatage contextuelle (sélection de texte) -->
886
- <BubbleMenu v-if="editor" :editor="editor" :should-show="bubbleShouldShow">
887
- <div class="q-tiptap__bubble">
888
- <button
889
- v-for="tool in bubbleTools"
890
- :key="tool.label"
891
- type="button"
892
- class="q-tiptap__bubble-btn"
893
- :class="{ 'q-tiptap__bubble-btn--active': tool.active }"
894
- :disabled="!editor.isEditable"
895
- :aria-label="tool.label"
896
- :title="tool.label"
897
- @mousedown.prevent
898
- @click="tool.run"
899
- >
900
- <Icon :icon="tool.icon" aria-hidden="true" />
901
- </button>
902
- <span class="q-tiptap__bubble-sep" />
903
- <button
904
- type="button"
905
- class="q-tiptap__bubble-btn q-tiptap__tool--color"
906
- :class="{ 'q-tiptap__bubble-btn--active': !!paletteCurrent }"
907
- :disabled="!editor.isEditable"
908
- aria-label="Text color"
909
- title="Text color"
910
- @mousedown.prevent
911
- @click="togglePalette($event)"
912
- >
913
- <Icon :icon="icons.palette" aria-hidden="true" />
914
- <span class="q-tiptap__color-bar" :style="{ backgroundColor: paletteCurrent || 'transparent' }" />
915
- </button>
916
- </div>
917
- </BubbleMenu>
918
-
919
- <!-- Palette de couleurs (téléportée au body — la toolbar est dans un
920
- conteneur overflow:hidden qui clipperait un popover absolu) -->
921
- <Teleport to="body">
922
- <div
923
- v-if="colorOpen"
924
- class="q-tiptap__palette"
925
- role="dialog"
926
- aria-label="Text color"
927
- :style="paletteStyle"
928
- >
929
- <div class="q-tiptap__palette-title">Text color</div>
930
- <div class="q-tiptap__palette-swatches">
931
- <button
932
- v-for="color in PRESET_COLORS"
933
- :key="color"
934
- type="button"
935
- class="q-tiptap__swatch"
936
- :class="{ 'q-tiptap__swatch--active': paletteCurrent.toLowerCase() === color.toLowerCase() }"
937
- :style="{ backgroundColor: color }"
938
- :title="color"
939
- :aria-label="color"
940
- @mousedown.prevent
941
- @click="applyColor(color)"
942
- />
943
- <button
944
- type="button"
945
- class="q-tiptap__swatch q-tiptap__swatch--none"
946
- :class="{ 'q-tiptap__swatch--active': !paletteCurrent }"
947
- title="No color"
948
- aria-label="No color"
949
- @mousedown.prevent
950
- @click="removeColor"
951
- />
952
- </div>
953
- <label class="q-tiptap__palette-custom">
954
- <input
955
- type="color"
956
- :value="paletteCurrent || picked"
957
- aria-label="Custom color"
958
- @input="onColorInput"
959
- />
960
- <span>Custom…</span>
961
- </label>
962
- </div>
963
- </Teleport>
964
-
965
- <!-- Saisie image / lien par URL (composants dnax.ui, pas de prompt) -->
966
- <q-dialog v-model="dialogOpen">
967
- <q-dialog-header :title="dialogTitle" description="Paste a URL" show-close />
968
- <div class="q-tiptap__dialog">
969
- <q-input
970
- v-model="dialogUrl"
971
- outlined
972
- dense
973
- :label="dialogUrlLabel"
974
- :placeholder="dialogUrlPlaceholder"
975
- />
976
- <q-input
977
- v-if="dialogMode === 'image'"
978
- v-model="dialogAlt"
979
- outlined
980
- dense
981
- label="Alt text (optional)"
982
- placeholder="Accessible description"
983
- />
984
- </div>
985
- <q-dialog-footer>
986
- <q-btn flat label="Cancel" @click="dialogOpen = false" />
987
- <q-btn
988
- no-caps
989
- color="primary"
990
- :label="dialogSubmitLabel"
991
- :disable="!dialogUrl.trim()"
992
- @click="submitDialog"
993
- />
994
- </q-dialog-footer>
995
- </q-dialog>
996
- </template>