@dnax/ui 0.0.26 → 0.0.28
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/QConfigProvider.vue +25 -0
- package/components/QDialog.vue +26 -12
- package/components/QEditorJs.vue +146 -0
- package/components/QFab.vue +19 -2
- 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 +12 -0
- package/lib/icons.ts +26 -0
- package/lib/q.ts +5 -1
- 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/module.ts +8 -0
- package/package.json +25 -1
- package/scripts/generate-exports.ts +10 -0
- package/styles/main.css +873 -32
|
@@ -0,0 +1,993 @@
|
|
|
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
|
+
document.addEventListener("mousedown", onDown)
|
|
297
|
+
document.addEventListener("keydown", onKey)
|
|
298
|
+
window.addEventListener("blur", close)
|
|
299
|
+
window.addEventListener("resize", close)
|
|
300
|
+
return () => {
|
|
301
|
+
document.removeEventListener("mousedown", onDown)
|
|
302
|
+
document.removeEventListener("keydown", onKey)
|
|
303
|
+
window.removeEventListener("blur", close)
|
|
304
|
+
window.removeEventListener("resize", close)
|
|
305
|
+
}
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
// — Image / lien : saisie par URL dans un q-dialog (pas de window.prompt) —
|
|
309
|
+
const dialogOpen = ref(false)
|
|
310
|
+
const dialogMode = ref<"image" | "link">("image")
|
|
311
|
+
const dialogUrl = ref("")
|
|
312
|
+
const dialogAlt = ref("")
|
|
313
|
+
/** true quand l'image sélectionnée doit être remplacée (pas dupliquée) */
|
|
314
|
+
const replacingImage = ref(false)
|
|
315
|
+
|
|
316
|
+
const dialogTitle = computed(() =>
|
|
317
|
+
dialogMode.value === "image" ? "Insert image" : "Insert link",
|
|
318
|
+
)
|
|
319
|
+
const dialogUrlLabel = computed(() =>
|
|
320
|
+
dialogMode.value === "image" ? "Image URL" : "Link URL",
|
|
321
|
+
)
|
|
322
|
+
const dialogUrlPlaceholder = computed(() =>
|
|
323
|
+
dialogMode.value === "image"
|
|
324
|
+
? "https://example.com/image.png"
|
|
325
|
+
: "https://example.com/page",
|
|
326
|
+
)
|
|
327
|
+
const dialogSubmitLabel = computed(() =>
|
|
328
|
+
dialogMode.value === "image" ? "Insert image" : "Insert link",
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
const openImageDialog = (ed: Editor) => {
|
|
332
|
+
replacingImage.value = ed.isActive("image")
|
|
333
|
+
const attrs = ed.getAttributes("image")
|
|
334
|
+
dialogUrl.value = typeof attrs.src === "string" ? (attrs.src as string) : ""
|
|
335
|
+
dialogAlt.value = typeof attrs.alt === "string" ? (attrs.alt as string) : ""
|
|
336
|
+
dialogMode.value = "image"
|
|
337
|
+
dialogOpen.value = true
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const openLinkDialog = (ed: Editor) => {
|
|
341
|
+
if (ed.state.selection.empty) return
|
|
342
|
+
const href = ed.getAttributes("link").href
|
|
343
|
+
dialogUrl.value = typeof href === "string" ? (href as string) : ""
|
|
344
|
+
dialogMode.value = "link"
|
|
345
|
+
dialogOpen.value = true
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const submitDialog = () => {
|
|
349
|
+
const ed = editor.value
|
|
350
|
+
const value = dialogUrl.value.trim()
|
|
351
|
+
if (!ed || !value) return
|
|
352
|
+
dialogOpen.value = false
|
|
353
|
+
if (dialogMode.value === "image") {
|
|
354
|
+
const alt = dialogAlt.value.trim()
|
|
355
|
+
const chain = ed.chain().focus()
|
|
356
|
+
if (replacingImage.value) {
|
|
357
|
+
const attrs: Record<string, unknown> = { src: value }
|
|
358
|
+
if (alt) attrs.alt = alt
|
|
359
|
+
chain.updateAttributes("image", attrs)
|
|
360
|
+
}
|
|
361
|
+
else {
|
|
362
|
+
const opts: { src: string; alt?: string } = { src: value }
|
|
363
|
+
if (alt) opts.alt = alt
|
|
364
|
+
chain.setImage(opts)
|
|
365
|
+
}
|
|
366
|
+
chain.run()
|
|
367
|
+
}
|
|
368
|
+
else {
|
|
369
|
+
ed.chain().focus().extendMarkRange("link").setLink({ href: value }).run()
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
// — Mentions (@) : filtre des items { label, value } passés en prop —
|
|
375
|
+
interface MentionPopupItem {
|
|
376
|
+
id: string
|
|
377
|
+
label: string
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const mentionItems = (query: string): MentionPopupItem[] => {
|
|
381
|
+
const q = query.trim().toLowerCase()
|
|
382
|
+
return (props.mentions ?? [])
|
|
383
|
+
.filter(
|
|
384
|
+
(item) =>
|
|
385
|
+
!q ||
|
|
386
|
+
item.label.toLowerCase().includes(q) ||
|
|
387
|
+
item.value.toLowerCase().includes(q),
|
|
388
|
+
)
|
|
389
|
+
.map((item) => ({ id: item.value, label: item.label }))
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Popup de suggestion (DOM stylé dnax.ui) montée via props.mount → la plugin
|
|
393
|
+
// l'ancre au curseur, la repositionne (scroll/resize) et fournit unmount().
|
|
394
|
+
const mentionPopupRender = () => {
|
|
395
|
+
let popup: HTMLElement | null = null
|
|
396
|
+
let list: HTMLElement | null = null
|
|
397
|
+
let unmount: (() => void) | null = null
|
|
398
|
+
let current: { items: MentionPopupItem[]; command: (item: MentionPopupItem) => void } | null = null
|
|
399
|
+
let index = 0
|
|
400
|
+
|
|
401
|
+
const rows = () =>
|
|
402
|
+
Array.from(list?.querySelectorAll<HTMLElement>(".q-tiptap__mention-item") ?? [])
|
|
403
|
+
|
|
404
|
+
const highlight = () => {
|
|
405
|
+
rows().forEach((row, i) =>
|
|
406
|
+
row.classList.toggle("q-tiptap__mention-item--active", i === index),
|
|
407
|
+
)
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const selectItem = () => {
|
|
411
|
+
const item = current?.items[index]
|
|
412
|
+
if (item && current) current.command(item)
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const renderList = (props: {
|
|
416
|
+
items: MentionPopupItem[]
|
|
417
|
+
command: (item: MentionPopupItem) => void
|
|
418
|
+
mount: (el: HTMLElement) => () => void
|
|
419
|
+
}) => {
|
|
420
|
+
if (!popup) {
|
|
421
|
+
popup = document.createElement("div")
|
|
422
|
+
popup.className = "q-tiptap__mention"
|
|
423
|
+
list = document.createElement("div")
|
|
424
|
+
list.className = "q-tiptap__mention-list"
|
|
425
|
+
popup.appendChild(list)
|
|
426
|
+
unmount = props.mount(popup)
|
|
427
|
+
}
|
|
428
|
+
if (!list) return
|
|
429
|
+
list.innerHTML = ""
|
|
430
|
+
const items = props.items ?? []
|
|
431
|
+
if (!items.length) {
|
|
432
|
+
const empty = document.createElement("div")
|
|
433
|
+
empty.className = "q-tiptap__mention-empty"
|
|
434
|
+
empty.textContent = "No results"
|
|
435
|
+
list.appendChild(empty)
|
|
436
|
+
return
|
|
437
|
+
}
|
|
438
|
+
index = Math.min(index, items.length - 1)
|
|
439
|
+
items.forEach((item, i) => {
|
|
440
|
+
const row = document.createElement("button")
|
|
441
|
+
row.type = "button"
|
|
442
|
+
row.className = "q-tiptap__mention-item"
|
|
443
|
+
row.textContent = `@${item.label}`
|
|
444
|
+
row.addEventListener("mousedown", (e) => e.preventDefault())
|
|
445
|
+
row.addEventListener("mouseenter", () => {
|
|
446
|
+
index = i
|
|
447
|
+
highlight()
|
|
448
|
+
})
|
|
449
|
+
row.addEventListener("click", () => {
|
|
450
|
+
index = i
|
|
451
|
+
selectItem()
|
|
452
|
+
})
|
|
453
|
+
list!.appendChild(row)
|
|
454
|
+
})
|
|
455
|
+
highlight()
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return {
|
|
459
|
+
onStart: (props: any) => {
|
|
460
|
+
current = props
|
|
461
|
+
index = 0
|
|
462
|
+
renderList(props)
|
|
463
|
+
},
|
|
464
|
+
onUpdate: (props: any) => {
|
|
465
|
+
current = props
|
|
466
|
+
renderList(props)
|
|
467
|
+
},
|
|
468
|
+
onExit: () => {
|
|
469
|
+
unmount?.()
|
|
470
|
+
popup = null
|
|
471
|
+
list = null
|
|
472
|
+
unmount = null
|
|
473
|
+
current = null
|
|
474
|
+
},
|
|
475
|
+
onKeyDown: ({ event }: { event: KeyboardEvent }) => {
|
|
476
|
+
const count = current?.items.length ?? 0
|
|
477
|
+
if (!count) return false
|
|
478
|
+
if (event.key === "ArrowDown") {
|
|
479
|
+
event.preventDefault()
|
|
480
|
+
index = (index + 1) % count
|
|
481
|
+
highlight()
|
|
482
|
+
return true
|
|
483
|
+
}
|
|
484
|
+
if (event.key === "ArrowUp") {
|
|
485
|
+
event.preventDefault()
|
|
486
|
+
index = (index - 1 + count) % count
|
|
487
|
+
highlight()
|
|
488
|
+
return true
|
|
489
|
+
}
|
|
490
|
+
if (event.key === "Enter" || event.key === "Tab") {
|
|
491
|
+
event.preventDefault()
|
|
492
|
+
selectItem()
|
|
493
|
+
return true
|
|
494
|
+
}
|
|
495
|
+
return false
|
|
496
|
+
},
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const HEADING_LEVELS = [1, 2, 3] as const
|
|
501
|
+
const ALIGNMENTS = ["left", "center", "right"] as const
|
|
502
|
+
// — Taille de police (sélecteur toolbar) —
|
|
503
|
+
const FONT_SIZES = [12, 14, 16, 18, 20, 24, 28, 32, 40] as const
|
|
504
|
+
|
|
505
|
+
const FONT_SIZE_OPTIONS = [
|
|
506
|
+
{ label: "Default", value: "" },
|
|
507
|
+
...FONT_SIZES.map((size) => ({ label: `${size} px`, value: String(size) })),
|
|
508
|
+
]
|
|
509
|
+
|
|
510
|
+
const currentFontSize = computed(() => {
|
|
511
|
+
void tick.value
|
|
512
|
+
const ed = editor.value
|
|
513
|
+
if (!ed) return ""
|
|
514
|
+
const size = (ed.getAttributes("textStyle").fontSize as string | undefined) ?? ""
|
|
515
|
+
const parsed = Number.parseInt(size, 10)
|
|
516
|
+
return Number.isFinite(parsed) ? String(parsed) : ""
|
|
517
|
+
})
|
|
518
|
+
|
|
519
|
+
const onFontSizeUpdate = (value: unknown) => {
|
|
520
|
+
const ed = editor.value
|
|
521
|
+
if (!ed) return
|
|
522
|
+
const size = String(value ?? "")
|
|
523
|
+
if (!size) ed.chain().focus().unsetFontSize().run()
|
|
524
|
+
else ed.chain().focus().setFontSize(`${size}px`).run()
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const toolGroups = computed<{ id: string; items: ToolView[] }[]>(() => {
|
|
528
|
+
void tick.value
|
|
529
|
+
const ed = editor.value
|
|
530
|
+
if (!ed) return []
|
|
531
|
+
|
|
532
|
+
const resolve = (spec: ToolSpec): ToolView => {
|
|
533
|
+
if (spec.kind === "color") {
|
|
534
|
+
const current =
|
|
535
|
+
typeof ed.getAttributes("textStyle").color === "string"
|
|
536
|
+
? (ed.getAttributes("textStyle").color as string)
|
|
537
|
+
: ""
|
|
538
|
+
return {
|
|
539
|
+
label: spec.label,
|
|
540
|
+
icon: spec.icon,
|
|
541
|
+
kind: "color",
|
|
542
|
+
active: !!current,
|
|
543
|
+
disabled: !ed.isEditable,
|
|
544
|
+
swatch: current || "transparent",
|
|
545
|
+
run: () => {},
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
return {
|
|
549
|
+
label: spec.label,
|
|
550
|
+
icon: spec.icon,
|
|
551
|
+
active: spec.active?.(ed) ?? false,
|
|
552
|
+
disabled: spec.disabled ? spec.disabled(ed) : !ed.isEditable,
|
|
553
|
+
run: () => spec.run(ed),
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const groups: { id: string; tools: ToolSpec[] }[] = [
|
|
558
|
+
{
|
|
559
|
+
id: "history",
|
|
560
|
+
tools: [
|
|
561
|
+
{
|
|
562
|
+
label: "Undo",
|
|
563
|
+
icon: icons.undo2,
|
|
564
|
+
disabled: (e) => !e.can().chain().undo().run(),
|
|
565
|
+
run: (e) => { e.chain().focus().undo().run() },
|
|
566
|
+
},
|
|
567
|
+
{
|
|
568
|
+
label: "Redo",
|
|
569
|
+
icon: icons.redo2,
|
|
570
|
+
disabled: (e) => !e.can().chain().redo().run(),
|
|
571
|
+
run: (e) => { e.chain().focus().redo().run() },
|
|
572
|
+
},
|
|
573
|
+
],
|
|
574
|
+
},
|
|
575
|
+
{
|
|
576
|
+
id: "blocks",
|
|
577
|
+
tools: [
|
|
578
|
+
{
|
|
579
|
+
label: "Paragraph",
|
|
580
|
+
icon: icons.pilcrow,
|
|
581
|
+
active: (e) => e.isActive("paragraph"),
|
|
582
|
+
run: (e) => { e.chain().focus().setParagraph().run() },
|
|
583
|
+
},
|
|
584
|
+
...HEADING_LEVELS.map((level) => ({
|
|
585
|
+
label: `Heading ${level}`,
|
|
586
|
+
icon: [icons.heading1, icons.heading2, icons.heading3][level - 1]!,
|
|
587
|
+
active: (e: Editor) => e.isActive("heading", { level }),
|
|
588
|
+
run: (e: Editor) => { e.chain().focus().toggleHeading({ level }).run() },
|
|
589
|
+
})),
|
|
590
|
+
],
|
|
591
|
+
},
|
|
592
|
+
{
|
|
593
|
+
id: "align",
|
|
594
|
+
tools: ALIGNMENTS.map((align) => {
|
|
595
|
+
const iconsByAlign = [icons.alignLeft, icons.alignCenter, icons.alignRight] as const
|
|
596
|
+
return {
|
|
597
|
+
label: `Align ${align}`,
|
|
598
|
+
icon: iconsByAlign[ALIGNMENTS.indexOf(align)]!,
|
|
599
|
+
// « left » est l'alignement implicite (aucun attribut posé)
|
|
600
|
+
active: (e: Editor) => {
|
|
601
|
+
const node = e.isActive("heading") ? "heading" : "paragraph"
|
|
602
|
+
const textAlign = e.getAttributes(node).textAlign
|
|
603
|
+
return (typeof textAlign === "string" ? textAlign : "left") === align
|
|
604
|
+
},
|
|
605
|
+
run: (e: Editor) => { e.chain().focus().setTextAlign(align).run() },
|
|
606
|
+
}
|
|
607
|
+
}),
|
|
608
|
+
},
|
|
609
|
+
{
|
|
610
|
+
id: "font-size",
|
|
611
|
+
tools: [],
|
|
612
|
+
},
|
|
613
|
+
{
|
|
614
|
+
id: "marks",
|
|
615
|
+
tools: [
|
|
616
|
+
{
|
|
617
|
+
label: "Bold",
|
|
618
|
+
icon: icons.bold,
|
|
619
|
+
active: (e) => e.isActive("bold"),
|
|
620
|
+
run: (e) => { e.chain().focus().toggleBold().run() },
|
|
621
|
+
},
|
|
622
|
+
{
|
|
623
|
+
label: "Italic",
|
|
624
|
+
icon: icons.italic,
|
|
625
|
+
active: (e) => e.isActive("italic"),
|
|
626
|
+
run: (e) => { e.chain().focus().toggleItalic().run() },
|
|
627
|
+
},
|
|
628
|
+
{
|
|
629
|
+
label: "Underline",
|
|
630
|
+
icon: icons.underline,
|
|
631
|
+
active: (e) => e.isActive("underline"),
|
|
632
|
+
run: (e) => { e.chain().focus().toggleUnderline().run() },
|
|
633
|
+
},
|
|
634
|
+
{
|
|
635
|
+
label: "Strikethrough",
|
|
636
|
+
icon: icons.strikethrough,
|
|
637
|
+
active: (e) => e.isActive("strike"),
|
|
638
|
+
run: (e) => { e.chain().focus().toggleStrike().run() },
|
|
639
|
+
},
|
|
640
|
+
{
|
|
641
|
+
label: "Inline code",
|
|
642
|
+
icon: icons.code,
|
|
643
|
+
active: (e) => e.isActive("code"),
|
|
644
|
+
run: (e) => { e.chain().focus().toggleCode().run() },
|
|
645
|
+
},
|
|
646
|
+
{
|
|
647
|
+
label: "Link",
|
|
648
|
+
icon: icons.link,
|
|
649
|
+
active: (e) => e.isActive("link"),
|
|
650
|
+
disabled: (e) => !e.isEditable || e.state.selection.empty,
|
|
651
|
+
run: openLinkDialog,
|
|
652
|
+
},
|
|
653
|
+
{
|
|
654
|
+
label: "Remove link",
|
|
655
|
+
icon: icons.unlink,
|
|
656
|
+
active: (e) => e.isActive("link"),
|
|
657
|
+
run: (e) => { e.chain().focus().extendMarkRange("link").unsetLink().run() },
|
|
658
|
+
},
|
|
659
|
+
{
|
|
660
|
+
label: "Text color",
|
|
661
|
+
icon: icons.palette,
|
|
662
|
+
kind: "color",
|
|
663
|
+
run: () => {},
|
|
664
|
+
},
|
|
665
|
+
{
|
|
666
|
+
label: "Remove color",
|
|
667
|
+
icon: icons.eraser,
|
|
668
|
+
active: (e) => typeof e.getAttributes("textStyle").color === "string",
|
|
669
|
+
run: (e) => { e.chain().focus().unsetColor().run() },
|
|
670
|
+
},
|
|
671
|
+
],
|
|
672
|
+
},
|
|
673
|
+
{
|
|
674
|
+
id: "blocks-list",
|
|
675
|
+
tools: [
|
|
676
|
+
{
|
|
677
|
+
label: "Bullet list",
|
|
678
|
+
icon: icons.list,
|
|
679
|
+
active: (e) => e.isActive("bulletList"),
|
|
680
|
+
run: (e) => { e.chain().focus().toggleBulletList().run() },
|
|
681
|
+
},
|
|
682
|
+
{
|
|
683
|
+
label: "Ordered list",
|
|
684
|
+
icon: icons.listOrdered,
|
|
685
|
+
active: (e) => e.isActive("orderedList"),
|
|
686
|
+
run: (e) => { e.chain().focus().toggleOrderedList().run() },
|
|
687
|
+
},
|
|
688
|
+
{
|
|
689
|
+
label: "Task list",
|
|
690
|
+
icon: icons.listChecks,
|
|
691
|
+
active: (e) => e.isActive("taskList"),
|
|
692
|
+
run: (e) => { e.chain().focus().toggleTaskList().run() },
|
|
693
|
+
},
|
|
694
|
+
{
|
|
695
|
+
label: "Blockquote",
|
|
696
|
+
icon: icons.quote,
|
|
697
|
+
active: (e) => e.isActive("blockquote"),
|
|
698
|
+
run: (e) => { e.chain().focus().toggleBlockquote().run() },
|
|
699
|
+
},
|
|
700
|
+
{
|
|
701
|
+
label: "Code block",
|
|
702
|
+
icon: icons.codeXml,
|
|
703
|
+
active: (e) => e.isActive("codeBlock"),
|
|
704
|
+
run: (e) => { e.chain().focus().toggleCodeBlock().run() },
|
|
705
|
+
},
|
|
706
|
+
{
|
|
707
|
+
label: "Image URL",
|
|
708
|
+
icon: icons.imagePlus,
|
|
709
|
+
active: (e) => e.isActive("image"),
|
|
710
|
+
run: openImageDialog,
|
|
711
|
+
},
|
|
712
|
+
{
|
|
713
|
+
label: "Horizontal rule",
|
|
714
|
+
icon: icons.minus,
|
|
715
|
+
run: (e) => { e.chain().focus().setHorizontalRule().run() },
|
|
716
|
+
},
|
|
717
|
+
],
|
|
718
|
+
},
|
|
719
|
+
{
|
|
720
|
+
id: "clear",
|
|
721
|
+
tools: [
|
|
722
|
+
{
|
|
723
|
+
label: "Clear formatting",
|
|
724
|
+
icon: icons.removeFormatting,
|
|
725
|
+
run: (e) => { e.chain().focus().clearNodes().unsetAllMarks().run() },
|
|
726
|
+
},
|
|
727
|
+
],
|
|
728
|
+
},
|
|
729
|
+
]
|
|
730
|
+
|
|
731
|
+
return groups.map((g) => ({ id: g.id, items: g.tools.map(resolve) }))
|
|
732
|
+
})
|
|
733
|
+
|
|
734
|
+
// — Bubble menu (barre contextuelle sur sélection de texte) —
|
|
735
|
+
const bubbleShouldShow = (props: any): boolean => {
|
|
736
|
+
const ed = props.editor as Editor | undefined
|
|
737
|
+
if (!ed || !ed.isEditable) return false
|
|
738
|
+
if (ed.isActive("codeBlock") || ed.isActive("image")) return false
|
|
739
|
+
return !ed.state.selection.empty
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
const bubbleTools = computed(() => {
|
|
743
|
+
void tick.value
|
|
744
|
+
const ed = editor.value
|
|
745
|
+
if (!ed) return []
|
|
746
|
+
const tool = (
|
|
747
|
+
label: string,
|
|
748
|
+
icon: string,
|
|
749
|
+
active: (e: Editor) => boolean,
|
|
750
|
+
run: (e: Editor) => void,
|
|
751
|
+
) => ({ label, icon, active: active(ed), run: () => run(ed) })
|
|
752
|
+
return [
|
|
753
|
+
tool("Bold", icons.bold, (e) => e.isActive("bold"), (e) => { e.chain().focus().toggleBold().run() }),
|
|
754
|
+
tool("Italic", icons.italic, (e) => e.isActive("italic"), (e) => { e.chain().focus().toggleItalic().run() }),
|
|
755
|
+
tool("Underline", icons.underline, (e) => e.isActive("underline"), (e) => { e.chain().focus().toggleUnderline().run() }),
|
|
756
|
+
tool("Strikethrough", icons.strikethrough, (e) => e.isActive("strike"), (e) => { e.chain().focus().toggleStrike().run() }),
|
|
757
|
+
tool("Inline code", icons.code, (e) => e.isActive("code"), (e) => { e.chain().focus().toggleCode().run() }),
|
|
758
|
+
tool("Link", icons.link, (e) => e.isActive("link"), openLinkDialog),
|
|
759
|
+
tool("Remove link", icons.unlink, (e) => e.isActive("link"), (e) => { e.chain().focus().extendMarkRange("link").unsetLink().run() }),
|
|
760
|
+
tool("Remove color", icons.eraser, () => !!paletteCurrent.value, (e) => { e.chain().focus().unsetColor().run() }),
|
|
761
|
+
tool("Clear formatting", icons.removeFormatting, () => false, (e) => { e.chain().focus().clearNodes().unsetAllMarks().run() }),
|
|
762
|
+
]
|
|
763
|
+
})
|
|
764
|
+
|
|
765
|
+
// Focus programmatique (defineExpose)
|
|
766
|
+
const focusEditor = () => editor.value?.commands.focus()
|
|
767
|
+
|
|
768
|
+
// — Table des matières : scroll interne + accès aux ancres —
|
|
769
|
+
/** Scrolle l'éditeur (et son conteneur) jusqu'au titre dont l'id TOC est donné */
|
|
770
|
+
const scrollToHeading = (id: string) => {
|
|
771
|
+
const ed = editor.value
|
|
772
|
+
if (!ed) return
|
|
773
|
+
const root = ed.view.dom as HTMLElement
|
|
774
|
+
const byAttr = root.querySelector<HTMLElement>(`[data-toc-id="${id}"], [id="${id}"]`)
|
|
775
|
+
const item = tocAnchors.value.find((a) => a.id === id)
|
|
776
|
+
const byPos =
|
|
777
|
+
typeof item?.pos === "number"
|
|
778
|
+
? (ed.view.nodeDOM(item.pos) as HTMLElement | null)
|
|
779
|
+
: null
|
|
780
|
+
;(byAttr ?? byPos)?.scrollIntoView({ behavior: "smooth", block: "start" })
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/** Dernière table des matières connue (copie sérialisable) */
|
|
784
|
+
const getToc = () => tocAnchors.value.map((item) => ({ ...item }))
|
|
785
|
+
|
|
786
|
+
defineExpose({
|
|
787
|
+
focus: focusEditor,
|
|
788
|
+
scrollToHeading,
|
|
789
|
+
getToc,
|
|
790
|
+
})
|
|
791
|
+
</script>
|
|
792
|
+
|
|
793
|
+
<template>
|
|
794
|
+
<div
|
|
795
|
+
class="q-tiptap"
|
|
796
|
+
:class="{
|
|
797
|
+
'q-tiptap--filled': filled,
|
|
798
|
+
'q-tiptap--dense': dense,
|
|
799
|
+
'q-tiptap--square': square,
|
|
800
|
+
'q-tiptap--disable': disable,
|
|
801
|
+
}"
|
|
802
|
+
>
|
|
803
|
+
<div v-if="toolbar && editor" class="q-tiptap__toolbar" role="toolbar" aria-label="Formatting">
|
|
804
|
+
<div v-for="group in toolGroups" :key="group.id" class="q-tiptap__group">
|
|
805
|
+
<div v-if="group.id === 'font-size'" class="q-tiptap__size" title="Font size">
|
|
806
|
+
<q-select
|
|
807
|
+
:model-value="currentFontSize"
|
|
808
|
+
:options="FONT_SIZE_OPTIONS"
|
|
809
|
+
emit-value
|
|
810
|
+
option-value="value"
|
|
811
|
+
option-label="label"
|
|
812
|
+
dense
|
|
813
|
+
outlined
|
|
814
|
+
:disable="!editor.isEditable"
|
|
815
|
+
aria-label="Font size"
|
|
816
|
+
class="q-tiptap__font-size"
|
|
817
|
+
@update:model-value="onFontSizeUpdate"
|
|
818
|
+
/>
|
|
819
|
+
</div>
|
|
820
|
+
<template v-if="group.id !== 'font-size'">
|
|
821
|
+
<template v-for="tool in group.items" :key="tool.label">
|
|
822
|
+
<button
|
|
823
|
+
v-if="tool.kind !== 'color'"
|
|
824
|
+
type="button"
|
|
825
|
+
class="q-tiptap__tool"
|
|
826
|
+
:class="{ 'q-tiptap__tool--active': tool.active }"
|
|
827
|
+
:disabled="tool.disabled"
|
|
828
|
+
:aria-label="tool.label"
|
|
829
|
+
:aria-pressed="tool.active"
|
|
830
|
+
:title="tool.label"
|
|
831
|
+
@mousedown.prevent
|
|
832
|
+
@click="tool.run"
|
|
833
|
+
>
|
|
834
|
+
<Icon :icon="tool.icon" aria-hidden="true" />
|
|
835
|
+
</button>
|
|
836
|
+
<button
|
|
837
|
+
v-else-if="tool.kind === 'color'"
|
|
838
|
+
type="button"
|
|
839
|
+
class="q-tiptap__tool q-tiptap__tool--color"
|
|
840
|
+
:class="{
|
|
841
|
+
'q-tiptap__tool--active': tool.active,
|
|
842
|
+
'q-tiptap__tool--disabled': tool.disabled,
|
|
843
|
+
}"
|
|
844
|
+
:disabled="tool.disabled"
|
|
845
|
+
:aria-label="tool.label"
|
|
846
|
+
:aria-haspopup="true"
|
|
847
|
+
:aria-expanded="colorOpen ? 'true' : 'false'"
|
|
848
|
+
:title="tool.label"
|
|
849
|
+
@mousedown.prevent
|
|
850
|
+
@click="togglePalette($event)"
|
|
851
|
+
>
|
|
852
|
+
<Icon :icon="tool.icon" aria-hidden="true" />
|
|
853
|
+
<span class="q-tiptap__color-bar" :style="{ backgroundColor: tool.swatch }" />
|
|
854
|
+
</button>
|
|
855
|
+
</template>
|
|
856
|
+
</template>
|
|
857
|
+
</div>
|
|
858
|
+
</div>
|
|
859
|
+
|
|
860
|
+
<div class="q-tiptap__body">
|
|
861
|
+
<span v-if="showPlaceholder" class="q-tiptap__placeholder" aria-hidden="true">
|
|
862
|
+
{{ placeholder }}
|
|
863
|
+
</span>
|
|
864
|
+
<div class="q-tiptap__editor" :style="editorStyle">
|
|
865
|
+
<editor-content :editor="editor" />
|
|
866
|
+
</div>
|
|
867
|
+
</div>
|
|
868
|
+
</div>
|
|
869
|
+
|
|
870
|
+
<!-- Drag handle : poignée pour déplacer les blocs (survol bord gauche) -->
|
|
871
|
+
<DragHandle
|
|
872
|
+
v-if="editor && !disable && !readonly"
|
|
873
|
+
:editor="editor"
|
|
874
|
+
:nested="DRAG_HANDLE_NESTED"
|
|
875
|
+
plugin-key="qTipTapDragHandle"
|
|
876
|
+
>
|
|
877
|
+
<span class="q-tiptap__drag-handle">
|
|
878
|
+
<Icon :icon="icons.gripVertical" aria-hidden="true" />
|
|
879
|
+
</span>
|
|
880
|
+
</DragHandle>
|
|
881
|
+
|
|
882
|
+
<!-- Bubble menu : barre de formatage contextuelle (sélection de texte) -->
|
|
883
|
+
<BubbleMenu v-if="editor" :editor="editor" :should-show="bubbleShouldShow">
|
|
884
|
+
<div class="q-tiptap__bubble">
|
|
885
|
+
<button
|
|
886
|
+
v-for="tool in bubbleTools"
|
|
887
|
+
:key="tool.label"
|
|
888
|
+
type="button"
|
|
889
|
+
class="q-tiptap__bubble-btn"
|
|
890
|
+
:class="{ 'q-tiptap__bubble-btn--active': tool.active }"
|
|
891
|
+
:disabled="!editor.isEditable"
|
|
892
|
+
:aria-label="tool.label"
|
|
893
|
+
:title="tool.label"
|
|
894
|
+
@mousedown.prevent
|
|
895
|
+
@click="tool.run"
|
|
896
|
+
>
|
|
897
|
+
<Icon :icon="tool.icon" aria-hidden="true" />
|
|
898
|
+
</button>
|
|
899
|
+
<span class="q-tiptap__bubble-sep" />
|
|
900
|
+
<button
|
|
901
|
+
type="button"
|
|
902
|
+
class="q-tiptap__bubble-btn q-tiptap__tool--color"
|
|
903
|
+
:class="{ 'q-tiptap__bubble-btn--active': !!paletteCurrent }"
|
|
904
|
+
:disabled="!editor.isEditable"
|
|
905
|
+
aria-label="Text color"
|
|
906
|
+
title="Text color"
|
|
907
|
+
@mousedown.prevent
|
|
908
|
+
@click="togglePalette($event)"
|
|
909
|
+
>
|
|
910
|
+
<Icon :icon="icons.palette" aria-hidden="true" />
|
|
911
|
+
<span class="q-tiptap__color-bar" :style="{ backgroundColor: paletteCurrent || 'transparent' }" />
|
|
912
|
+
</button>
|
|
913
|
+
</div>
|
|
914
|
+
</BubbleMenu>
|
|
915
|
+
|
|
916
|
+
<!-- Palette de couleurs (téléportée au body — la toolbar est dans un
|
|
917
|
+
conteneur overflow:hidden qui clipperait un popover absolu) -->
|
|
918
|
+
<Teleport to="body">
|
|
919
|
+
<div
|
|
920
|
+
v-if="colorOpen"
|
|
921
|
+
class="q-tiptap__palette"
|
|
922
|
+
role="dialog"
|
|
923
|
+
aria-label="Text color"
|
|
924
|
+
:style="paletteStyle"
|
|
925
|
+
>
|
|
926
|
+
<div class="q-tiptap__palette-title">Text color</div>
|
|
927
|
+
<div class="q-tiptap__palette-swatches">
|
|
928
|
+
<button
|
|
929
|
+
v-for="color in PRESET_COLORS"
|
|
930
|
+
:key="color"
|
|
931
|
+
type="button"
|
|
932
|
+
class="q-tiptap__swatch"
|
|
933
|
+
:class="{ 'q-tiptap__swatch--active': paletteCurrent.toLowerCase() === color.toLowerCase() }"
|
|
934
|
+
:style="{ backgroundColor: color }"
|
|
935
|
+
:title="color"
|
|
936
|
+
:aria-label="color"
|
|
937
|
+
@mousedown.prevent
|
|
938
|
+
@click="applyColor(color)"
|
|
939
|
+
/>
|
|
940
|
+
<button
|
|
941
|
+
type="button"
|
|
942
|
+
class="q-tiptap__swatch q-tiptap__swatch--none"
|
|
943
|
+
:class="{ 'q-tiptap__swatch--active': !paletteCurrent }"
|
|
944
|
+
title="No color"
|
|
945
|
+
aria-label="No color"
|
|
946
|
+
@mousedown.prevent
|
|
947
|
+
@click="removeColor"
|
|
948
|
+
/>
|
|
949
|
+
</div>
|
|
950
|
+
<label class="q-tiptap__palette-custom">
|
|
951
|
+
<input
|
|
952
|
+
type="color"
|
|
953
|
+
:value="paletteCurrent || picked"
|
|
954
|
+
aria-label="Custom color"
|
|
955
|
+
@input="onColorInput"
|
|
956
|
+
/>
|
|
957
|
+
<span>Custom…</span>
|
|
958
|
+
</label>
|
|
959
|
+
</div>
|
|
960
|
+
</Teleport>
|
|
961
|
+
|
|
962
|
+
<!-- Saisie image / lien par URL (composants dnax.ui, pas de prompt) -->
|
|
963
|
+
<q-dialog v-model="dialogOpen">
|
|
964
|
+
<q-dialog-header :title="dialogTitle" description="Paste a URL" show-close />
|
|
965
|
+
<div class="q-tiptap__dialog">
|
|
966
|
+
<q-input
|
|
967
|
+
v-model="dialogUrl"
|
|
968
|
+
outlined
|
|
969
|
+
dense
|
|
970
|
+
:label="dialogUrlLabel"
|
|
971
|
+
:placeholder="dialogUrlPlaceholder"
|
|
972
|
+
/>
|
|
973
|
+
<q-input
|
|
974
|
+
v-if="dialogMode === 'image'"
|
|
975
|
+
v-model="dialogAlt"
|
|
976
|
+
outlined
|
|
977
|
+
dense
|
|
978
|
+
label="Alt text (optional)"
|
|
979
|
+
placeholder="Accessible description"
|
|
980
|
+
/>
|
|
981
|
+
</div>
|
|
982
|
+
<q-dialog-footer>
|
|
983
|
+
<q-btn flat label="Cancel" @click="dialogOpen = false" />
|
|
984
|
+
<q-btn
|
|
985
|
+
no-caps
|
|
986
|
+
color="primary"
|
|
987
|
+
:label="dialogSubmitLabel"
|
|
988
|
+
:disable="!dialogUrl.trim()"
|
|
989
|
+
@click="submitDialog"
|
|
990
|
+
/>
|
|
991
|
+
</q-dialog-footer>
|
|
992
|
+
</q-dialog>
|
|
993
|
+
</template>
|