@tekkare/romulus 0.1.176 → 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
@@ -4,7 +4,7 @@
4
4
  "compatibility": {
5
5
  "nuxt": ">=3.16.0"
6
6
  },
7
- "version": "0.1.175",
7
+ "version": "0.1.176",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "0.8.4",
10
10
  "unbuild": "2.0.0"
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekkare/romulus",
3
- "version": "0.1.176",
3
+ "version": "0.1.177",
4
4
  "description": "Romulus Design System - Nuxt module by Tekkare",
5
5
  "license": "MIT",
6
6
  "type": "module",