@tekkare/romulus 0.1.175 → 0.1.177
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/module.json
CHANGED
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
RmNoteEditor - la redaction d'une note du panneau « Notes ».
|
|
3
|
+
|
|
4
|
+
Une note n'est pas du texte libre : elle est faite de blocs typés que
|
|
5
|
+
RmNotes rend chacun a sa maniere (un encadre, un exemple en chasse fixe,
|
|
6
|
+
une liste de definitions, un renvoi vers un ecran). Un champ de texte riche
|
|
7
|
+
ordinaire produirait du HTML, que le panneau ne lit pas, et perdrait les
|
|
8
|
+
encadres des la premiere correction.
|
|
9
|
+
|
|
10
|
+
L'editeur travaille donc sur la meme structure que le panneau : une ligne
|
|
11
|
+
par bloc, son genre choisi dans une liste, et une mise en page qui ressemble
|
|
12
|
+
au rendu final. Ce qu'on voit en ecrivant est ce qui sortira.
|
|
13
|
+
|
|
14
|
+
Le gras est la seule mise en forme dans le texte, parce que c'est la seule
|
|
15
|
+
que le panneau connait. Un bouton le pose sur la selection, plutot que le
|
|
16
|
+
seul raccourci du navigateur : le raccourci reste, mais rien ne le montre, et
|
|
17
|
+
une barre de mise en forme se cherche la ou elle se voit. Le collage est
|
|
18
|
+
force en texte brut, sans quoi une copie depuis Word ramenerait des styles
|
|
19
|
+
que la note ne saura pas rendre.
|
|
20
|
+
-->
|
|
21
|
+
<script setup lang="ts">
|
|
22
|
+
import { computed, ref, watch } from 'vue'
|
|
23
|
+
import type { RmNoteBlock, RmNoteSection } from './Notes.vue'
|
|
24
|
+
|
|
25
|
+
const props = withDefaults(defineProps<{
|
|
26
|
+
/** Etiquette posee au-dessus de l'editeur. */
|
|
27
|
+
label?: string
|
|
28
|
+
disabled?: boolean
|
|
29
|
+
lang?: 'fr' | 'en'
|
|
30
|
+
}>(), {
|
|
31
|
+
label: undefined,
|
|
32
|
+
disabled: false,
|
|
33
|
+
lang: 'fr',
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const model = defineModel<RmNoteSection[]>({ default: () => [] })
|
|
37
|
+
|
|
38
|
+
const WORDS = {
|
|
39
|
+
fr: {
|
|
40
|
+
bold: 'Gras',
|
|
41
|
+
hint: 'Le gras porte sur la sélection de la zone où vous écrivez.',
|
|
42
|
+
add: 'Ajouter un bloc',
|
|
43
|
+
up: 'Monter le bloc',
|
|
44
|
+
down: 'Descendre le bloc',
|
|
45
|
+
remove: 'Supprimer le bloc',
|
|
46
|
+
addItem: 'Ajouter une ligne',
|
|
47
|
+
removeItem: 'Supprimer la ligne',
|
|
48
|
+
empty: 'Cette note est vide. Ajoutez un premier bloc.',
|
|
49
|
+
sectionTitle: 'Titre de section',
|
|
50
|
+
term: 'Terme',
|
|
51
|
+
def: 'Définition',
|
|
52
|
+
linkTo: 'Chemin (/hubr/...)',
|
|
53
|
+
linkLabel: 'Libellé du renvoi',
|
|
54
|
+
kinds: {
|
|
55
|
+
text: 'Paragraphe',
|
|
56
|
+
list: 'Liste à puces',
|
|
57
|
+
terms: 'Définitions',
|
|
58
|
+
callout: 'Encadré',
|
|
59
|
+
example: 'Exemple',
|
|
60
|
+
section: 'Titre de section',
|
|
61
|
+
link: 'Renvoi vers un écran',
|
|
62
|
+
},
|
|
63
|
+
tones: { info: 'Information', warning: 'Attention' },
|
|
64
|
+
},
|
|
65
|
+
en: {
|
|
66
|
+
bold: 'Bold',
|
|
67
|
+
hint: 'Bold applies to the selection in the field you are writing in.',
|
|
68
|
+
add: 'Add a block',
|
|
69
|
+
up: 'Move block up',
|
|
70
|
+
down: 'Move block down',
|
|
71
|
+
remove: 'Remove block',
|
|
72
|
+
addItem: 'Add a line',
|
|
73
|
+
removeItem: 'Remove line',
|
|
74
|
+
empty: 'This note is empty. Add a first block.',
|
|
75
|
+
sectionTitle: 'Section title',
|
|
76
|
+
term: 'Term',
|
|
77
|
+
def: 'Definition',
|
|
78
|
+
linkTo: 'Path (/hubr/...)',
|
|
79
|
+
linkLabel: 'Link label',
|
|
80
|
+
kinds: {
|
|
81
|
+
text: 'Paragraph',
|
|
82
|
+
list: 'Bulleted list',
|
|
83
|
+
terms: 'Definitions',
|
|
84
|
+
callout: 'Callout',
|
|
85
|
+
example: 'Example',
|
|
86
|
+
section: 'Section title',
|
|
87
|
+
link: 'Link to a screen',
|
|
88
|
+
},
|
|
89
|
+
tones: { info: 'Information', warning: 'Warning' },
|
|
90
|
+
},
|
|
91
|
+
} as const
|
|
92
|
+
|
|
93
|
+
const T = computed(() => WORDS[props.lang] ?? WORDS.fr)
|
|
94
|
+
|
|
95
|
+
type RowKind = 'section' | 'text' | 'list' | 'terms' | 'callout' | 'example' | 'link'
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Un champ ou le gras est permis.
|
|
99
|
+
*
|
|
100
|
+
* `text` porte le balisage (`**gras**`), `html` ce que la zone editable
|
|
101
|
+
* affiche. `html` n'est jamais reecrit pendant la frappe : le remplacer
|
|
102
|
+
* replacerait le curseur au debut du champ a chaque lettre.
|
|
103
|
+
*/
|
|
104
|
+
interface Field { text: string, html: string }
|
|
105
|
+
|
|
106
|
+
interface Row {
|
|
107
|
+
key: number
|
|
108
|
+
kind: RowKind
|
|
109
|
+
/** section */
|
|
110
|
+
title: string
|
|
111
|
+
/** text, callout */
|
|
112
|
+
body: Field
|
|
113
|
+
/** example */
|
|
114
|
+
plain: string
|
|
115
|
+
tone: 'info' | 'warning'
|
|
116
|
+
/** list */
|
|
117
|
+
items: Field[]
|
|
118
|
+
/** terms */
|
|
119
|
+
terms: { term: string, def: string }[]
|
|
120
|
+
/** link */
|
|
121
|
+
to: string
|
|
122
|
+
label: string
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
let seq = 0
|
|
126
|
+
|
|
127
|
+
function field(text = ''): Field {
|
|
128
|
+
return { text, html: toHtml(text) }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function blankRow(kind: RowKind): Row {
|
|
132
|
+
return {
|
|
133
|
+
key: ++seq,
|
|
134
|
+
kind,
|
|
135
|
+
title: '',
|
|
136
|
+
body: field(),
|
|
137
|
+
plain: '',
|
|
138
|
+
tone: 'info',
|
|
139
|
+
items: kind === 'list' ? [field()] : [],
|
|
140
|
+
terms: kind === 'terms' ? [{ term: '', def: '' }] : [],
|
|
141
|
+
to: '',
|
|
142
|
+
label: '',
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const escapeHtml = (text: string) =>
|
|
147
|
+
text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
148
|
+
|
|
149
|
+
/** `**gras**` vers la zone editable. Rien d'autre n'est interprete. */
|
|
150
|
+
function toHtml(text: string) {
|
|
151
|
+
return escapeHtml(text ?? '')
|
|
152
|
+
.split(/(\*\*[^*]+\*\*)/g)
|
|
153
|
+
.filter(Boolean)
|
|
154
|
+
.map(chunk =>
|
|
155
|
+
chunk.startsWith('**') && chunk.endsWith('**')
|
|
156
|
+
? `<b>${chunk.slice(2, -2)}</b>`
|
|
157
|
+
: chunk)
|
|
158
|
+
.join('')
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* La zone editable vers le balisage.
|
|
163
|
+
*
|
|
164
|
+
* On parcourt les noeuds plutot que d'eplucher une chaine : le navigateur
|
|
165
|
+
* produit `<b>`, `<strong>`, parfois les deux imbriques, et une expression
|
|
166
|
+
* reguliere sur du HTML se trompe des le premier cas tordu. Tout ce qui n'est
|
|
167
|
+
* ni du texte ni du gras est aplati : la note ne sait rendre que ces deux la.
|
|
168
|
+
*/
|
|
169
|
+
function fromHtml(node: Node, bold = false): string {
|
|
170
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
171
|
+
const text = (node.textContent ?? '').replace(/\s+/g, ' ')
|
|
172
|
+
if (!text.trim()) return text
|
|
173
|
+
return bold ? `**${text.trim()}**` : text
|
|
174
|
+
}
|
|
175
|
+
if (node.nodeType !== Node.ELEMENT_NODE) return ''
|
|
176
|
+
|
|
177
|
+
const element = node as HTMLElement
|
|
178
|
+
const tag = element.tagName.toLowerCase()
|
|
179
|
+
if (tag === 'br') return ' '
|
|
180
|
+
|
|
181
|
+
const strong = bold || tag === 'b' || tag === 'strong'
|
|
182
|
+
let out = ''
|
|
183
|
+
for (const child of Array.from(element.childNodes)) out += fromHtml(child, strong)
|
|
184
|
+
// Deux `**gras**` colles se relisent comme un seul : on les recolle.
|
|
185
|
+
return out.replace(/\*\*\*\*/g, '')
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Ce qu'une zone editable vient de produire, en balisage. */
|
|
189
|
+
function readEditable(element: HTMLElement) {
|
|
190
|
+
return fromHtml(element).replace(/\s+/g, ' ').trim()
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/* --- le modele, en lignes --- */
|
|
194
|
+
|
|
195
|
+
function toRows(sections: RmNoteSection[]): Row[] {
|
|
196
|
+
const rows: Row[] = []
|
|
197
|
+
|
|
198
|
+
for (const section of sections ?? []) {
|
|
199
|
+
if (section.title) {
|
|
200
|
+
const row = blankRow('section')
|
|
201
|
+
row.title = section.title
|
|
202
|
+
rows.push(row)
|
|
203
|
+
}
|
|
204
|
+
for (const block of section.blocks ?? []) rows.push(toRow(block))
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return rows
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function toRow(block: RmNoteBlock): Row {
|
|
211
|
+
if (block.type === 'list') {
|
|
212
|
+
const row = blankRow('list')
|
|
213
|
+
row.items = (block.items ?? []).map(item => field(item))
|
|
214
|
+
if (!row.items.length) row.items = [field()]
|
|
215
|
+
return row
|
|
216
|
+
}
|
|
217
|
+
if (block.type === 'terms') {
|
|
218
|
+
const row = blankRow('terms')
|
|
219
|
+
row.terms = (block.items ?? []).map(item => ({ term: item.term, def: item.def ?? '' }))
|
|
220
|
+
if (!row.terms.length) row.terms = [{ term: '', def: '' }]
|
|
221
|
+
return row
|
|
222
|
+
}
|
|
223
|
+
if (block.type === 'callout') {
|
|
224
|
+
const row = blankRow('callout')
|
|
225
|
+
row.body = field(block.text)
|
|
226
|
+
row.tone = block.tone ?? 'info'
|
|
227
|
+
return row
|
|
228
|
+
}
|
|
229
|
+
if (block.type === 'example') {
|
|
230
|
+
const row = blankRow('example')
|
|
231
|
+
row.plain = block.text
|
|
232
|
+
return row
|
|
233
|
+
}
|
|
234
|
+
if (block.type === 'link') {
|
|
235
|
+
const row = blankRow('link')
|
|
236
|
+
row.to = block.to
|
|
237
|
+
row.label = block.label
|
|
238
|
+
return row
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const row = blankRow('text')
|
|
242
|
+
row.body = field(block.text)
|
|
243
|
+
return row
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Les lignes, regroupees en sections.
|
|
248
|
+
*
|
|
249
|
+
* Un titre de section ouvre la suivante ; ce qui le precede forme la section
|
|
250
|
+
* d'entete, sans titre. Une ligne vide ne sort pas : un bloc qu'on ajoute
|
|
251
|
+
* puis qu'on laisse en blanc ne doit pas apparaitre dans le panneau.
|
|
252
|
+
*/
|
|
253
|
+
function toSections(rows: Row[]): RmNoteSection[] {
|
|
254
|
+
const sections: RmNoteSection[] = []
|
|
255
|
+
let current: RmNoteSection = { blocks: [] }
|
|
256
|
+
|
|
257
|
+
for (const row of rows) {
|
|
258
|
+
if (row.kind === 'section') {
|
|
259
|
+
if (current.blocks.length) sections.push(current)
|
|
260
|
+
current = { title: row.title.trim(), blocks: [] }
|
|
261
|
+
continue
|
|
262
|
+
}
|
|
263
|
+
const block = toBlock(row)
|
|
264
|
+
if (block) current.blocks.push(block)
|
|
265
|
+
}
|
|
266
|
+
if (current.blocks.length) sections.push(current)
|
|
267
|
+
|
|
268
|
+
return sections
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function toBlock(row: Row): RmNoteBlock | null {
|
|
272
|
+
if (row.kind === 'list') {
|
|
273
|
+
const items = row.items.map(item => item.text.trim()).filter(Boolean)
|
|
274
|
+
return items.length ? { type: 'list', items } : null
|
|
275
|
+
}
|
|
276
|
+
if (row.kind === 'terms') {
|
|
277
|
+
const items = row.terms
|
|
278
|
+
.map(item => ({ term: item.term.trim(), def: item.def.trim() }))
|
|
279
|
+
.filter(item => item.term)
|
|
280
|
+
.map(item => (item.def ? { term: item.term, def: item.def } : { term: item.term }))
|
|
281
|
+
return items.length ? { type: 'terms', items } : null
|
|
282
|
+
}
|
|
283
|
+
if (row.kind === 'callout') {
|
|
284
|
+
const text = row.body.text.trim()
|
|
285
|
+
return text ? { type: 'callout', text, tone: row.tone } : null
|
|
286
|
+
}
|
|
287
|
+
if (row.kind === 'example') {
|
|
288
|
+
const text = row.plain.trim()
|
|
289
|
+
return text ? { type: 'example', text } : null
|
|
290
|
+
}
|
|
291
|
+
if (row.kind === 'link') {
|
|
292
|
+
const to = row.to.trim()
|
|
293
|
+
const label = row.label.trim()
|
|
294
|
+
return to && label ? { type: 'link', to, label } : null
|
|
295
|
+
}
|
|
296
|
+
const text = row.body.text.trim()
|
|
297
|
+
return text ? { type: 'text', text } : null
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const rows = ref<Row[]>(toRows(model.value))
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Le modele ne redescend dans les lignes que s'il a change ailleurs qu'ici.
|
|
304
|
+
* Sans cette garde, chaque frappe reconstruirait les lignes, donc les zones
|
|
305
|
+
* editables, donc perdrait le curseur.
|
|
306
|
+
*/
|
|
307
|
+
const lastEmitted = ref<RmNoteSection[] | null>(null)
|
|
308
|
+
|
|
309
|
+
watch(model, (value) => {
|
|
310
|
+
if (value === lastEmitted.value) return
|
|
311
|
+
rows.value = toRows(value)
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
function emitRows() {
|
|
315
|
+
const sections = toSections(rows.value)
|
|
316
|
+
lastEmitted.value = sections
|
|
317
|
+
model.value = sections
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/* --- edition --- */
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* La zone ou l'on ecrit, retenue au focus.
|
|
324
|
+
*
|
|
325
|
+
* Le bouton « Gras » est hors des zones editables : sans cette reference, il
|
|
326
|
+
* ne saurait pas sur quel texte agir, et cliquer dessus aurait deja fait
|
|
327
|
+
* perdre la selection (d'ou le `mousedown` retenu cote modele).
|
|
328
|
+
*/
|
|
329
|
+
const active = ref<{ el: HTMLElement, field: Field } | null>(null)
|
|
330
|
+
|
|
331
|
+
function onEditable(event: Event, target: Field) {
|
|
332
|
+
target.text = readEditable(event.target as HTMLElement)
|
|
333
|
+
emitRows()
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function onFocus(event: FocusEvent, target: Field) {
|
|
337
|
+
active.value = { el: event.target as HTMLElement, field: target }
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* `execCommand` est deprecie et sans remplacant : l'API Editing Context n'est
|
|
342
|
+
* implementee nulle part, et tous les navigateurs le portent encore. Ecrire a
|
|
343
|
+
* la main « mettre en gras la selection » serait une bibliotheque a soi seul.
|
|
344
|
+
*/
|
|
345
|
+
function bold() {
|
|
346
|
+
const target = active.value
|
|
347
|
+
if (props.disabled || !target) return
|
|
348
|
+
target.el.focus()
|
|
349
|
+
document.execCommand('bold')
|
|
350
|
+
target.field.text = readEditable(target.el)
|
|
351
|
+
emitRows()
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Le collage entre en texte brut : un collage riche ramene du HTML illisible. */
|
|
355
|
+
function onPaste(event: ClipboardEvent) {
|
|
356
|
+
event.preventDefault()
|
|
357
|
+
const text = event.clipboardData?.getData('text/plain') ?? ''
|
|
358
|
+
document.execCommand('insertText', false, text.replace(/\s+/g, ' '))
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function changeKind(row: Row, kind: RowKind) {
|
|
362
|
+
if (row.kind === kind) return
|
|
363
|
+
// Le texte deja saisi suit le changement de genre quand les deux en
|
|
364
|
+
// portent : passer un paragraphe en encadre ne doit pas l'effacer.
|
|
365
|
+
const carried = row.kind === 'example' ? row.plain : row.body.text
|
|
366
|
+
const next = blankRow(kind)
|
|
367
|
+
next.key = row.key
|
|
368
|
+
if (kind === 'example') next.plain = carried
|
|
369
|
+
else if (kind === 'section') next.title = carried.replace(/\*\*/g, '')
|
|
370
|
+
else if (kind === 'list') next.items = [field(carried)]
|
|
371
|
+
else if (kind !== 'link' && kind !== 'terms') next.body = field(carried)
|
|
372
|
+
|
|
373
|
+
rows.value = rows.value.map(item => (item.key === row.key ? next : item))
|
|
374
|
+
emitRows()
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function addRow(kind: RowKind) {
|
|
378
|
+
rows.value = [...rows.value, blankRow(kind)]
|
|
379
|
+
emitRows()
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function move(index: number, delta: number) {
|
|
383
|
+
const next = [...rows.value]
|
|
384
|
+
const target = index + delta
|
|
385
|
+
if (target < 0 || target >= next.length) return
|
|
386
|
+
const [row] = next.splice(index, 1)
|
|
387
|
+
next.splice(target, 0, row!)
|
|
388
|
+
rows.value = next
|
|
389
|
+
emitRows()
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function removeRow(index: number) {
|
|
393
|
+
rows.value = rows.value.filter((_, position) => position !== index)
|
|
394
|
+
emitRows()
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function addItem(row: Row) {
|
|
398
|
+
if (row.kind === 'list') row.items = [...row.items, field()]
|
|
399
|
+
else row.terms = [...row.terms, { term: '', def: '' }]
|
|
400
|
+
emitRows()
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function removeItem(row: Row, index: number) {
|
|
404
|
+
if (row.kind === 'list') row.items = row.items.filter((_, position) => position !== index)
|
|
405
|
+
else row.terms = row.terms.filter((_, position) => position !== index)
|
|
406
|
+
emitRows()
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const KINDS: RowKind[] = ['text', 'list', 'terms', 'callout', 'example', 'section', 'link']
|
|
410
|
+
</script>
|
|
411
|
+
|
|
412
|
+
<template>
|
|
413
|
+
<div class="rm-note-editor" :class="{ 'rm-note-editor--disabled': disabled }">
|
|
414
|
+
<div v-if="label || !disabled" class="rm-note-editor__head">
|
|
415
|
+
<span v-if="label" class="rm-note-editor__label">{{ label }}</span>
|
|
416
|
+
<span class="rm-note-editor__tools">
|
|
417
|
+
<span class="rm-note-editor__hint">{{ T.hint }}</span>
|
|
418
|
+
<button
|
|
419
|
+
type="button"
|
|
420
|
+
class="rm-note-editor__bold"
|
|
421
|
+
:disabled="disabled || !active"
|
|
422
|
+
:title="T.bold"
|
|
423
|
+
:aria-label="T.bold"
|
|
424
|
+
@mousedown.prevent
|
|
425
|
+
@click="bold()"
|
|
426
|
+
>
|
|
427
|
+
<RmIcon name="TextB" :size="14" weight="bold" />
|
|
428
|
+
</button>
|
|
429
|
+
</span>
|
|
430
|
+
</div>
|
|
431
|
+
|
|
432
|
+
<p v-if="!rows.length" class="rm-note-editor__empty">{{ T.empty }}</p>
|
|
433
|
+
|
|
434
|
+
<div v-else class="rm-note-editor__rows">
|
|
435
|
+
<section
|
|
436
|
+
v-for="(row, index) in rows"
|
|
437
|
+
:key="row.key"
|
|
438
|
+
class="rm-note-editor__row"
|
|
439
|
+
:class="`rm-note-editor__row--${row.kind}`"
|
|
440
|
+
>
|
|
441
|
+
<header class="rm-note-editor__bar">
|
|
442
|
+
<select
|
|
443
|
+
class="rm-note-editor__kind"
|
|
444
|
+
:value="row.kind"
|
|
445
|
+
:disabled="disabled"
|
|
446
|
+
:aria-label="T.add"
|
|
447
|
+
@change="changeKind(row, ($event.target as HTMLSelectElement).value as RowKind)"
|
|
448
|
+
>
|
|
449
|
+
<!-- `selected` en plus de la valeur du champ : le navigateur pose la
|
|
450
|
+
valeur avant que les options existent, et la liste repartirait
|
|
451
|
+
sur la premiere de toutes. -->
|
|
452
|
+
<option v-for="kind in KINDS" :key="kind" :value="kind" :selected="kind === row.kind">
|
|
453
|
+
{{ T.kinds[kind] }}
|
|
454
|
+
</option>
|
|
455
|
+
</select>
|
|
456
|
+
|
|
457
|
+
<select
|
|
458
|
+
v-if="row.kind === 'callout'"
|
|
459
|
+
v-model="row.tone"
|
|
460
|
+
class="rm-note-editor__kind"
|
|
461
|
+
:disabled="disabled"
|
|
462
|
+
:aria-label="T.kinds.callout"
|
|
463
|
+
@change="emitRows()"
|
|
464
|
+
>
|
|
465
|
+
<option value="info">{{ T.tones.info }}</option>
|
|
466
|
+
<option value="warning">{{ T.tones.warning }}</option>
|
|
467
|
+
</select>
|
|
468
|
+
|
|
469
|
+
<span class="rm-note-editor__spacer" />
|
|
470
|
+
|
|
471
|
+
<button
|
|
472
|
+
type="button"
|
|
473
|
+
class="rm-note-editor__act"
|
|
474
|
+
:disabled="disabled || index === 0"
|
|
475
|
+
:title="T.up"
|
|
476
|
+
:aria-label="T.up"
|
|
477
|
+
@click="move(index, -1)"
|
|
478
|
+
>
|
|
479
|
+
<RmIcon name="ArrowUp" :size="14" />
|
|
480
|
+
</button>
|
|
481
|
+
<button
|
|
482
|
+
type="button"
|
|
483
|
+
class="rm-note-editor__act"
|
|
484
|
+
:disabled="disabled || index === rows.length - 1"
|
|
485
|
+
:title="T.down"
|
|
486
|
+
:aria-label="T.down"
|
|
487
|
+
@click="move(index, 1)"
|
|
488
|
+
>
|
|
489
|
+
<RmIcon name="ArrowDown" :size="14" />
|
|
490
|
+
</button>
|
|
491
|
+
<button
|
|
492
|
+
type="button"
|
|
493
|
+
class="rm-note-editor__act rm-note-editor__act--danger"
|
|
494
|
+
:disabled="disabled"
|
|
495
|
+
:title="T.remove"
|
|
496
|
+
:aria-label="T.remove"
|
|
497
|
+
@click="removeRow(index)"
|
|
498
|
+
>
|
|
499
|
+
<RmIcon name="Trash" :size="14" />
|
|
500
|
+
</button>
|
|
501
|
+
</header>
|
|
502
|
+
|
|
503
|
+
<input
|
|
504
|
+
v-if="row.kind === 'section'"
|
|
505
|
+
v-model="row.title"
|
|
506
|
+
class="rm-note-editor__input rm-note-editor__input--section"
|
|
507
|
+
:placeholder="T.sectionTitle"
|
|
508
|
+
:disabled="disabled"
|
|
509
|
+
@input="emitRows()"
|
|
510
|
+
>
|
|
511
|
+
|
|
512
|
+
<textarea
|
|
513
|
+
v-else-if="row.kind === 'example'"
|
|
514
|
+
v-model="row.plain"
|
|
515
|
+
class="rm-note-editor__input rm-note-editor__input--example"
|
|
516
|
+
rows="2"
|
|
517
|
+
:disabled="disabled"
|
|
518
|
+
@input="emitRows()"
|
|
519
|
+
/>
|
|
520
|
+
|
|
521
|
+
<div v-else-if="row.kind === 'link'" class="rm-note-editor__pair">
|
|
522
|
+
<input
|
|
523
|
+
v-model="row.to"
|
|
524
|
+
class="rm-note-editor__input"
|
|
525
|
+
:placeholder="T.linkTo"
|
|
526
|
+
:disabled="disabled"
|
|
527
|
+
@input="emitRows()"
|
|
528
|
+
>
|
|
529
|
+
<input
|
|
530
|
+
v-model="row.label"
|
|
531
|
+
class="rm-note-editor__input"
|
|
532
|
+
:placeholder="T.linkLabel"
|
|
533
|
+
:disabled="disabled"
|
|
534
|
+
@input="emitRows()"
|
|
535
|
+
>
|
|
536
|
+
</div>
|
|
537
|
+
|
|
538
|
+
<ul v-else-if="row.kind === 'list'" class="rm-note-editor__items">
|
|
539
|
+
<li v-for="(item, itemIndex) in row.items" :key="itemIndex" class="rm-note-editor__item">
|
|
540
|
+
<span class="rm-note-editor__bullet">•</span>
|
|
541
|
+
<div
|
|
542
|
+
class="rm-note-editor__editable"
|
|
543
|
+
:contenteditable="!disabled"
|
|
544
|
+
role="textbox"
|
|
545
|
+
@input="onEditable($event, item)"
|
|
546
|
+
@focus="onFocus($event, item)"
|
|
547
|
+
@paste="onPaste"
|
|
548
|
+
v-html="item.html"
|
|
549
|
+
/>
|
|
550
|
+
<button
|
|
551
|
+
type="button"
|
|
552
|
+
class="rm-note-editor__act"
|
|
553
|
+
:disabled="disabled"
|
|
554
|
+
:title="T.removeItem"
|
|
555
|
+
:aria-label="T.removeItem"
|
|
556
|
+
@click="removeItem(row, itemIndex)"
|
|
557
|
+
>
|
|
558
|
+
<RmIcon name="X" :size="12" />
|
|
559
|
+
</button>
|
|
560
|
+
</li>
|
|
561
|
+
<li>
|
|
562
|
+
<button type="button" class="rm-note-editor__more" :disabled="disabled" @click="addItem(row)">
|
|
563
|
+
<RmIcon name="Plus" :size="12" />
|
|
564
|
+
{{ T.addItem }}
|
|
565
|
+
</button>
|
|
566
|
+
</li>
|
|
567
|
+
</ul>
|
|
568
|
+
|
|
569
|
+
<ul v-else-if="row.kind === 'terms'" class="rm-note-editor__items">
|
|
570
|
+
<li v-for="(item, itemIndex) in row.terms" :key="itemIndex" class="rm-note-editor__item">
|
|
571
|
+
<input
|
|
572
|
+
v-model="item.term"
|
|
573
|
+
class="rm-note-editor__input rm-note-editor__input--term"
|
|
574
|
+
:placeholder="T.term"
|
|
575
|
+
:disabled="disabled"
|
|
576
|
+
@input="emitRows()"
|
|
577
|
+
>
|
|
578
|
+
<input
|
|
579
|
+
v-model="item.def"
|
|
580
|
+
class="rm-note-editor__input"
|
|
581
|
+
:placeholder="T.def"
|
|
582
|
+
:disabled="disabled"
|
|
583
|
+
@input="emitRows()"
|
|
584
|
+
>
|
|
585
|
+
<button
|
|
586
|
+
type="button"
|
|
587
|
+
class="rm-note-editor__act"
|
|
588
|
+
:disabled="disabled"
|
|
589
|
+
:title="T.removeItem"
|
|
590
|
+
:aria-label="T.removeItem"
|
|
591
|
+
@click="removeItem(row, itemIndex)"
|
|
592
|
+
>
|
|
593
|
+
<RmIcon name="X" :size="12" />
|
|
594
|
+
</button>
|
|
595
|
+
</li>
|
|
596
|
+
<li>
|
|
597
|
+
<button type="button" class="rm-note-editor__more" :disabled="disabled" @click="addItem(row)">
|
|
598
|
+
<RmIcon name="Plus" :size="12" />
|
|
599
|
+
{{ T.addItem }}
|
|
600
|
+
</button>
|
|
601
|
+
</li>
|
|
602
|
+
</ul>
|
|
603
|
+
|
|
604
|
+
<div
|
|
605
|
+
v-else
|
|
606
|
+
class="rm-note-editor__editable"
|
|
607
|
+
:class="{
|
|
608
|
+
'rm-note-editor__editable--info': row.kind === 'callout' && row.tone === 'info',
|
|
609
|
+
'rm-note-editor__editable--warning': row.kind === 'callout' && row.tone === 'warning',
|
|
610
|
+
}"
|
|
611
|
+
:contenteditable="!disabled"
|
|
612
|
+
role="textbox"
|
|
613
|
+
@input="onEditable($event, row.body)"
|
|
614
|
+
@focus="onFocus($event, row.body)"
|
|
615
|
+
@paste="onPaste"
|
|
616
|
+
v-html="row.body.html"
|
|
617
|
+
/>
|
|
618
|
+
</section>
|
|
619
|
+
</div>
|
|
620
|
+
|
|
621
|
+
<div class="rm-note-editor__add">
|
|
622
|
+
<button
|
|
623
|
+
v-for="kind in KINDS"
|
|
624
|
+
:key="kind"
|
|
625
|
+
type="button"
|
|
626
|
+
class="rm-note-editor__more"
|
|
627
|
+
:disabled="disabled"
|
|
628
|
+
@click="addRow(kind)"
|
|
629
|
+
>
|
|
630
|
+
<RmIcon name="Plus" :size="12" />
|
|
631
|
+
{{ T.kinds[kind] }}
|
|
632
|
+
</button>
|
|
633
|
+
</div>
|
|
634
|
+
</div>
|
|
635
|
+
</template>
|
|
636
|
+
|
|
637
|
+
<style scoped>
|
|
638
|
+
.rm-note-editor{display:flex;flex-direction:column;font-family:var(--font-sans);gap:var(--space-8)}.rm-note-editor--disabled{opacity:.6}.rm-note-editor__head{align-items:baseline;display:flex;gap:var(--space-8);justify-content:space-between}.rm-note-editor__label{color:var(--text-secondary);font-size:var(--font-13);font-weight:var(--weight-medium)}.rm-note-editor__tools{align-items:center;display:inline-flex;gap:var(--space-8)}.rm-note-editor__bold{align-items:center;background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-sm);color:var(--text-secondary);cursor:pointer;display:inline-flex;height:26px;justify-content:center;padding:0;width:26px}.rm-note-editor__bold:hover:not(:disabled){background:var(--bg-hover);color:var(--color-primary)}.rm-note-editor__bold:disabled{cursor:default;opacity:.4}.rm-note-editor__hint{color:var(--text-muted);font-size:var(--font-11)}.rm-note-editor__empty{border:1px dashed var(--border-default);border-radius:var(--radius-md);color:var(--text-muted);font-size:var(--font-13);margin:0;padding:var(--space-16);text-align:center}.rm-note-editor__rows{display:flex;flex-direction:column;gap:var(--space-8)}.rm-note-editor__row{background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-md);display:flex;flex-direction:column;gap:var(--space-6);padding:var(--space-8)}.rm-note-editor__bar{align-items:center;display:flex;gap:var(--space-4)}.rm-note-editor__spacer{flex:1}.rm-note-editor__kind{background:var(--bg-surface-2);border:1px solid var(--border-soft);border-radius:var(--radius-sm);color:var(--text-secondary);font-family:inherit;font-size:var(--font-11);height:26px;padding:0 var(--space-6)}.rm-note-editor__act{align-items:center;background:none;border:none;border-radius:var(--radius-sm);color:var(--text-muted);cursor:pointer;display:inline-flex;height:24px;justify-content:center;padding:0;width:24px}.rm-note-editor__act:hover:not(:disabled){background:var(--bg-hover);color:var(--color-secondary)}.rm-note-editor__act--danger:hover:not(:disabled){color:var(--color-danger)}.rm-note-editor__act:disabled{cursor:default;opacity:.4}.rm-note-editor__editable,.rm-note-editor__input{background:var(--bg-surface-2);border:1px solid var(--border-soft);border-radius:var(--radius-sm);color:var(--text-primary);font-family:inherit;font-size:var(--font-13);line-height:1.5;min-height:30px;outline:none;padding:var(--space-6) var(--space-8)}.rm-note-editor__editable:focus,.rm-note-editor__input:focus{border-color:var(--color-primary)}.rm-note-editor__input--section{font-size:var(--font-11);font-weight:var(--weight-bold);letter-spacing:.06em;text-transform:uppercase}.rm-note-editor__input--example{font-family:var(--font-mono,monospace);font-size:var(--font-12);resize:vertical}.rm-note-editor__editable--info{background:var(--bg-hover);border-color:var(--color-info,var(--color-secondary))}.rm-note-editor__editable--warning{background:var(--bg-surface-3);border-color:var(--color-warning)}.rm-note-editor__pair{display:flex;gap:var(--space-6)}.rm-note-editor__pair .rm-note-editor__input{flex:1;min-width:0}.rm-note-editor__items{display:flex;flex-direction:column;gap:var(--space-4);list-style:none;margin:0;padding:0}.rm-note-editor__item{align-items:center;display:flex;gap:var(--space-6)}.rm-note-editor__item .rm-note-editor__editable,.rm-note-editor__item .rm-note-editor__input{flex:1;min-width:0}.rm-note-editor__input--term{flex:0 0 30%;font-weight:var(--weight-semibold)}.rm-note-editor__bullet{color:var(--text-muted)}.rm-note-editor__add{display:flex;flex-wrap:wrap;gap:var(--space-4)}.rm-note-editor__more{align-items:center;background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-pill);color:var(--text-secondary);cursor:pointer;display:inline-flex;font-family:inherit;font-size:var(--font-11);gap:var(--space-4);padding:var(--space-4) var(--space-8)}.rm-note-editor__more:hover:not(:disabled){background:var(--bg-hover);color:var(--color-primary)}.rm-note-editor__more:disabled{cursor:default;opacity:.4}
|
|
639
|
+
</style>
|
|
@@ -83,16 +83,23 @@ const props = withDefaults(defineProps<{
|
|
|
83
83
|
description?: string
|
|
84
84
|
/** Pastille d'auteur en haut a droite (« TEKKARE »). Vide : pas de pastille. */
|
|
85
85
|
badge?: string
|
|
86
|
+
/**
|
|
87
|
+
* Pose un crayon sur chaque note, qui emet `edit`. Reserve a l'equipe qui
|
|
88
|
+
* redige : le panneau ne decide pas du droit, il l'affiche. L'ecriture est
|
|
89
|
+
* revalidee la ou elle a lieu, jamais sur la presence du bouton.
|
|
90
|
+
*/
|
|
91
|
+
editable?: boolean
|
|
86
92
|
lang?: 'fr' | 'en'
|
|
87
93
|
}>(), {
|
|
88
94
|
categories: () => [],
|
|
89
95
|
title: 'Notes',
|
|
90
96
|
description: undefined,
|
|
91
97
|
badge: undefined,
|
|
98
|
+
editable: false,
|
|
92
99
|
lang: 'fr',
|
|
93
100
|
})
|
|
94
101
|
|
|
95
|
-
const emit = defineEmits<{ navigate: [to: string] }>()
|
|
102
|
+
const emit = defineEmits<{ navigate: [to: string], edit: [id: string] }>()
|
|
96
103
|
|
|
97
104
|
const WORDS = {
|
|
98
105
|
fr: {
|
|
@@ -102,6 +109,7 @@ const WORDS = {
|
|
|
102
109
|
reading: 'min de lecture',
|
|
103
110
|
expand: 'Déplier la note',
|
|
104
111
|
collapse: 'Replier la note',
|
|
112
|
+
edit: 'Modifier la note',
|
|
105
113
|
emptyTitle: 'Aucune note pour cet écran',
|
|
106
114
|
emptyText: "Les notes sont rattachées à la page affichée : changez d'écran pour en voir d'autres.",
|
|
107
115
|
noMatch: 'Aucune note ne correspond',
|
|
@@ -114,6 +122,7 @@ const WORDS = {
|
|
|
114
122
|
reading: 'min read',
|
|
115
123
|
expand: 'Expand note',
|
|
116
124
|
collapse: 'Collapse note',
|
|
125
|
+
edit: 'Edit note',
|
|
117
126
|
emptyTitle: 'No note for this screen',
|
|
118
127
|
emptyText: 'Notes belong to the page on display: switch screens to see others.',
|
|
119
128
|
noMatch: 'No note matches',
|
|
@@ -316,6 +325,17 @@ function onLink(to: string) {
|
|
|
316
325
|
</span>
|
|
317
326
|
</button>
|
|
318
327
|
|
|
328
|
+
<button
|
|
329
|
+
v-if="editable"
|
|
330
|
+
type="button"
|
|
331
|
+
class="rm-notes__caret"
|
|
332
|
+
:aria-label="T.edit"
|
|
333
|
+
:title="T.edit"
|
|
334
|
+
@click="emit('edit', note.id)"
|
|
335
|
+
>
|
|
336
|
+
<RmIcon name="PencilSimple" :size="14" />
|
|
337
|
+
</button>
|
|
338
|
+
|
|
319
339
|
<button
|
|
320
340
|
type="button"
|
|
321
341
|
class="rm-notes__caret"
|
|
@@ -60,14 +60,22 @@ function place() {
|
|
|
60
60
|
|
|
61
61
|
const wanted = props.align === 'start' ? box.left : box.right - size.width
|
|
62
62
|
const left = Math.max(EDGE, Math.min(wanted, window.innerWidth - size.width - EDGE))
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
|
|
64
|
+
// Le cote le plus large l'emporte : sous le bouton tant que ca tient, au
|
|
65
|
+
// dessus sinon. Une liste plus haute que la fenetre ne peut tenir nulle
|
|
66
|
+
// part : elle prend alors la place disponible et defile, plutot que de
|
|
67
|
+
// sortir par le bas comme le faisait un simple basculement.
|
|
68
|
+
const room = { below: window.innerHeight - box.bottom - 6 - EDGE, above: box.top - 6 - EDGE }
|
|
69
|
+
const below = room.below >= size.height || room.below >= room.above
|
|
70
|
+
const max = Math.max(120, Math.round(below ? room.below : room.above))
|
|
71
|
+
const height = Math.min(size.height, max)
|
|
72
|
+
const top = below ? box.bottom + 6 : box.top - 6 - height
|
|
66
73
|
|
|
67
74
|
style.value = {
|
|
68
75
|
position: 'fixed',
|
|
69
76
|
left: `${Math.round(left)}px`,
|
|
70
|
-
top: `${Math.round(top)}px`,
|
|
77
|
+
top: `${Math.round(Math.max(EDGE, top))}px`,
|
|
78
|
+
maxHeight: `${max}px`,
|
|
71
79
|
}
|
|
72
80
|
}
|
|
73
81
|
|
|
@@ -133,5 +141,5 @@ onBeforeUnmount(() => {
|
|
|
133
141
|
</template>
|
|
134
142
|
|
|
135
143
|
<style scoped>
|
|
136
|
-
.rm-pop{background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-lg);box-shadow:var(--shadow-lg);max-width:min(92vw,360px);min-width:220px;padding:var(--space-12);z-index:var(--z-drawer)}.rm-pop-enter-active,.rm-pop-leave-active{transition:transform var(--duration-fast) var(--ease-out),opacity var(--duration-fast) linear}.rm-pop-enter-from,.rm-pop-leave-to{opacity:0;transform:translateY(-4px)}@media (prefers-reduced-motion:reduce){.rm-pop-enter-active,.rm-pop-leave-active{transition:opacity var(--duration-fast) linear}.rm-pop-enter-from,.rm-pop-leave-to{transform:none}}
|
|
144
|
+
.rm-pop{background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-lg);box-shadow:var(--shadow-lg);box-sizing:border-box;max-width:min(92vw,360px);min-width:220px;overflow-y:auto;padding:var(--space-12);z-index:var(--z-drawer)}.rm-pop-enter-active,.rm-pop-leave-active{transition:transform var(--duration-fast) var(--ease-out),opacity var(--duration-fast) linear}.rm-pop-enter-from,.rm-pop-leave-to{opacity:0;transform:translateY(-4px)}@media (prefers-reduced-motion:reduce){.rm-pop-enter-active,.rm-pop-leave-active{transition:opacity var(--duration-fast) linear}.rm-pop-enter-from,.rm-pop-leave-to{transform:none}}
|
|
137
145
|
</style>
|