@tekkare/romulus 0.1.169 → 0.1.171

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.168",
7
+ "version": "0.1.170",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "0.8.4",
10
10
  "unbuild": "2.0.0"
@@ -0,0 +1,304 @@
1
+ <!--
2
+ RmRichText - champ de texte mis en forme, pour du HTML redactionnel court.
3
+
4
+ Jumeau de RmInput et RmTextarea cote dessin (meme etiquette, meme bordure,
5
+ meme focus), avec une barre d'outils et une zone editable a la place du
6
+ champ nu.
7
+
8
+ Il existe pour une raison precise : les descriptions du referentiel Directus
9
+ sont stockees en HTML. Montrees dans un `<textarea>`, elles arrivent en
10
+ balises et en entites (`&eacute;`), illisibles des la premiere phrase. Ici la
11
+ zone rend le HTML tel qu'il s'affichera dans la fiche.
12
+
13
+ Volontairement pauvre : gras, italique, listes, lien. C'est ce que produit
14
+ l'editeur de Directus, et une description de source n'a pas besoin de plus.
15
+ Le bouton « HTML » ouvre la source pour les cas que la barre ne couvre pas.
16
+
17
+ Le collage est force en texte brut : coller depuis Word ou depuis une page
18
+ web injecte des `<span style>` et des classes qui ressortent ensuite dans
19
+ toutes les apps qui affichent la fiche.
20
+ -->
21
+ <script setup lang="ts">
22
+ import { computed, nextTick, ref, watch } from 'vue'
23
+
24
+ const props = withDefaults(defineProps<{
25
+ /** Etiquette posee au-dessus du champ. */
26
+ label?: string
27
+ placeholder?: string
28
+ /** Message d'erreur sous le champ. */
29
+ error?: string
30
+ disabled?: boolean
31
+ /** Hauteur visible, en lignes de texte. */
32
+ rows?: number
33
+ lang?: 'fr' | 'en'
34
+ }>(), {
35
+ label: undefined,
36
+ placeholder: undefined,
37
+ error: undefined,
38
+ disabled: false,
39
+ rows: 6,
40
+ lang: 'fr',
41
+ })
42
+
43
+ const model = defineModel<string>({ default: '' })
44
+
45
+ const WORDS = {
46
+ fr: {
47
+ bold: 'Gras',
48
+ italic: 'Italique',
49
+ bulleted: 'Liste à puces',
50
+ numbered: 'Liste numérotée',
51
+ link: 'Lien',
52
+ unlink: 'Retirer le lien',
53
+ html: 'Modifier le HTML',
54
+ url: 'Adresse du lien',
55
+ apply: 'Appliquer',
56
+ cancel: 'Annuler',
57
+ },
58
+ en: {
59
+ bold: 'Bold',
60
+ italic: 'Italic',
61
+ bulleted: 'Bulleted list',
62
+ numbered: 'Numbered list',
63
+ link: 'Link',
64
+ unlink: 'Remove link',
65
+ html: 'Edit HTML',
66
+ url: 'Link address',
67
+ apply: 'Apply',
68
+ cancel: 'Cancel',
69
+ },
70
+ } as const
71
+
72
+ const t = computed(() => WORDS[props.lang] ?? WORDS.fr)
73
+
74
+ const editor = ref<HTMLElement | null>(null)
75
+ const showSource = ref(false)
76
+ const showLinkField = ref(false)
77
+ const linkUrl = ref('')
78
+
79
+ /**
80
+ * Le modele ne repart dans la zone que quand il change ailleurs qu'ici.
81
+ *
82
+ * Reecrire `innerHTML` a chaque frappe replace le curseur au debut : la saisie
83
+ * part a l'envers des la deuxieme lettre.
84
+ */
85
+ const lastEmitted = ref<string | null>(null)
86
+
87
+ function syncFromModel() {
88
+ const el = editor.value
89
+ if (!el || model.value === lastEmitted.value) return
90
+ el.innerHTML = model.value || ''
91
+ }
92
+
93
+ watch(model, syncFromModel)
94
+ watch(editor, syncFromModel)
95
+ watch(showSource, async (source) => {
96
+ if (source) return
97
+ await nextTick()
98
+ lastEmitted.value = null
99
+ syncFromModel()
100
+ })
101
+
102
+ function emitFromEditor() {
103
+ const html = editor.value?.innerHTML ?? ''
104
+ lastEmitted.value = html
105
+ model.value = html
106
+ }
107
+
108
+ /**
109
+ * `execCommand` est deprecie et sans remplacant : l'API Editing Context n'est
110
+ * implementee nulle part. Tous les navigateurs le portent encore, et une
111
+ * reimplementation a la main de « mettre en gras la selection » serait une
112
+ * bibliotheque a elle seule.
113
+ */
114
+ function run(command: string, value?: string) {
115
+ if (props.disabled) return
116
+ editor.value?.focus()
117
+ document.execCommand(command, false, value)
118
+ emitFromEditor()
119
+ refreshState()
120
+ }
121
+
122
+ const state = ref<Record<string, boolean>>({})
123
+
124
+ function refreshState() {
125
+ const read = (command: string) => {
126
+ try {
127
+ return document.queryCommandState(command)
128
+ }
129
+ catch {
130
+ return false
131
+ }
132
+ }
133
+ state.value = {
134
+ bold: read('bold'),
135
+ italic: read('italic'),
136
+ insertUnorderedList: read('insertUnorderedList'),
137
+ insertOrderedList: read('insertOrderedList'),
138
+ }
139
+ }
140
+
141
+ /** Le HTML colle d'ailleurs arrive avec ses styles : seul le texte entre. */
142
+ function onPaste(event: ClipboardEvent) {
143
+ event.preventDefault()
144
+ const text = event.clipboardData?.getData('text/plain') ?? ''
145
+ document.execCommand('insertText', false, text)
146
+ emitFromEditor()
147
+ }
148
+
149
+ function openLinkField() {
150
+ if (props.disabled) return
151
+ linkUrl.value = ''
152
+ showLinkField.value = true
153
+ }
154
+
155
+ function applyLink() {
156
+ const url = linkUrl.value.trim()
157
+ if (url) run('createLink', url)
158
+ showLinkField.value = false
159
+ }
160
+
161
+ const minHeight = computed(() => `${Math.max(2, props.rows) * 22 + 16}px`)
162
+ </script>
163
+
164
+ <template>
165
+ <div class="rm-rich-wrap">
166
+ <label v-if="label" class="rm-rich-label">{{ label }}</label>
167
+
168
+ <div class="rm-rich" :class="{ 'rm-rich--error': error, 'rm-rich--disabled': disabled }">
169
+ <div class="rm-rich__bar">
170
+ <button
171
+ type="button"
172
+ class="rm-rich__tool"
173
+ :class="{ 'rm-rich__tool--on': state.bold }"
174
+ :aria-pressed="!!state.bold"
175
+ :aria-label="t.bold"
176
+ :title="t.bold"
177
+ :disabled="disabled || showSource"
178
+ @click="run('bold')"
179
+ >
180
+ <RmIcon name="TextB" :size="15" weight="bold" />
181
+ </button>
182
+ <button
183
+ type="button"
184
+ class="rm-rich__tool"
185
+ :class="{ 'rm-rich__tool--on': state.italic }"
186
+ :aria-pressed="!!state.italic"
187
+ :aria-label="t.italic"
188
+ :title="t.italic"
189
+ :disabled="disabled || showSource"
190
+ @click="run('italic')"
191
+ >
192
+ <RmIcon name="TextItalic" :size="15" />
193
+ </button>
194
+
195
+ <span class="rm-rich__sep" />
196
+
197
+ <button
198
+ type="button"
199
+ class="rm-rich__tool"
200
+ :class="{ 'rm-rich__tool--on': state.insertUnorderedList }"
201
+ :aria-pressed="!!state.insertUnorderedList"
202
+ :aria-label="t.bulleted"
203
+ :title="t.bulleted"
204
+ :disabled="disabled || showSource"
205
+ @click="run('insertUnorderedList')"
206
+ >
207
+ <RmIcon name="ListBullets" :size="15" />
208
+ </button>
209
+ <button
210
+ type="button"
211
+ class="rm-rich__tool"
212
+ :class="{ 'rm-rich__tool--on': state.insertOrderedList }"
213
+ :aria-pressed="!!state.insertOrderedList"
214
+ :aria-label="t.numbered"
215
+ :title="t.numbered"
216
+ :disabled="disabled || showSource"
217
+ @click="run('insertOrderedList')"
218
+ >
219
+ <RmIcon name="ListNumbers" :size="15" />
220
+ </button>
221
+
222
+ <span class="rm-rich__sep" />
223
+
224
+ <button
225
+ type="button"
226
+ class="rm-rich__tool"
227
+ :aria-label="t.link"
228
+ :title="t.link"
229
+ :disabled="disabled || showSource"
230
+ @click="openLinkField"
231
+ >
232
+ <RmIcon name="Link" :size="15" />
233
+ </button>
234
+ <button
235
+ type="button"
236
+ class="rm-rich__tool"
237
+ :aria-label="t.unlink"
238
+ :title="t.unlink"
239
+ :disabled="disabled || showSource"
240
+ @click="run('unlink')"
241
+ >
242
+ <RmIcon name="LinkBreak" :size="15" />
243
+ </button>
244
+
245
+ <button
246
+ type="button"
247
+ class="rm-rich__tool rm-rich__tool--end"
248
+ :class="{ 'rm-rich__tool--on': showSource }"
249
+ :aria-pressed="showSource"
250
+ :aria-label="t.html"
251
+ :title="t.html"
252
+ :disabled="disabled"
253
+ @click="showSource = !showSource"
254
+ >
255
+ <RmIcon name="CodeSimple" :size="15" />
256
+ </button>
257
+ </div>
258
+
259
+ <div v-if="showLinkField" class="rm-rich__link">
260
+ <input
261
+ v-model="linkUrl"
262
+ class="rm-rich__link-field"
263
+ type="url"
264
+ :placeholder="t.url"
265
+ @keydown.enter.prevent="applyLink"
266
+ @keydown.esc.prevent="showLinkField = false"
267
+ >
268
+ <button type="button" class="rm-rich__link-btn" @click="applyLink">{{ t.apply }}</button>
269
+ <button type="button" class="rm-rich__link-btn" @click="showLinkField = false">{{ t.cancel }}</button>
270
+ </div>
271
+
272
+ <textarea
273
+ v-if="showSource"
274
+ :value="model"
275
+ class="rm-rich__source"
276
+ :style="{ minHeight }"
277
+ :disabled="disabled"
278
+ spellcheck="false"
279
+ @input="model = ($event.target as HTMLTextAreaElement).value"
280
+ />
281
+ <div
282
+ v-else
283
+ ref="editor"
284
+ class="rm-rich__area"
285
+ :style="{ minHeight }"
286
+ :contenteditable="!disabled"
287
+ role="textbox"
288
+ aria-multiline="true"
289
+ :data-placeholder="placeholder"
290
+ @input="emitFromEditor"
291
+ @paste="onPaste"
292
+ @keyup="refreshState"
293
+ @mouseup="refreshState"
294
+ @focus="refreshState"
295
+ />
296
+ </div>
297
+
298
+ <p v-if="error" class="rm-rich-error">{{ error }}</p>
299
+ </div>
300
+ </template>
301
+
302
+ <style scoped>
303
+ .rm-rich-wrap{display:flex;flex-direction:column;gap:var(--space-6)}.rm-rich-label{color:var(--text-secondary);font-size:var(--font-13);font-weight:var(--weight-medium)}.rm-rich{background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-md);overflow:hidden}.rm-rich:focus-within{border-color:var(--color-primary)}.rm-rich--error{border-color:var(--color-danger)}.rm-rich--disabled{opacity:.6}.rm-rich__bar{align-items:center;background:var(--bg-surface-2);border-bottom:1px solid var(--border-soft);display:flex;gap:var(--space-2);padding:var(--space-4) var(--space-6)}.rm-rich__tool{align-items:center;background:none;border:none;border-radius:var(--radius-sm);color:var(--text-secondary);cursor:pointer;display:inline-flex;height:26px;justify-content:center;padding:0;width:26px}.rm-rich__tool:hover:not(:disabled){background:var(--bg-hover);color:var(--color-secondary)}.rm-rich__tool--on{background:var(--bg-hover);color:var(--color-primary)}.rm-rich__tool:disabled{cursor:default;opacity:.4}.rm-rich__tool--end{margin-left:auto}.rm-rich__sep{background:var(--border-soft);height:16px;width:1px}.rm-rich__link{align-items:center;border-bottom:1px solid var(--border-soft);display:flex;gap:var(--space-6);padding:var(--space-6)}.rm-rich__link-field{background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-sm);color:var(--text-primary);flex:1;font-family:inherit;font-size:var(--font-13);min-width:0;padding:4px var(--space-8)}.rm-rich__link-btn{background:none;border:none;color:var(--color-primary);cursor:pointer;font-family:inherit;font-size:var(--font-12);font-weight:var(--weight-medium);padding:4px var(--space-6);text-decoration:underline;text-underline-offset:3px}.rm-rich__area,.rm-rich__source{color:var(--text-primary);font-family:inherit;font-size:var(--font-13);line-height:1.55;padding:var(--space-8) var(--space-12)}.rm-rich__area{outline:none;overflow-y:auto}.rm-rich__area:empty:before{color:var(--text-muted);content:attr(data-placeholder)}.rm-rich__area :deep(p){margin:0 0 var(--space-8)}.rm-rich__area :deep(p:last-child){margin-bottom:0}.rm-rich__area :deep(ol),.rm-rich__area :deep(ul){margin:0 0 var(--space-8);padding-left:var(--space-20)}.rm-rich__area :deep(a){color:var(--color-primary);text-decoration:underline}.rm-rich__source{background:var(--bg-surface);border:none;display:block;font-family:var(--font-mono,ui-monospace,SFMono-Regular,Menlo,monospace);font-size:var(--font-12);outline:none;resize:vertical;width:100%}
304
+ </style>
@@ -53,6 +53,11 @@ const props = withDefaults(defineProps<{
53
53
  highlight?: boolean
54
54
  /** Bouton de copie du titre. */
55
55
  copyable?: boolean
56
+ /**
57
+ * Bouton d'edition dans l'en-tete. Reserve aux profils qui ont le droit de
58
+ * corriger le referentiel : la fiche n'en decide pas, elle l'affiche.
59
+ */
60
+ editable?: boolean
56
61
  }>(), {
57
62
  country: undefined,
58
63
  flag: undefined,
@@ -67,9 +72,10 @@ const props = withDefaults(defineProps<{
67
72
  lang: 'fr',
68
73
  highlight: false,
69
74
  copyable: true,
75
+ editable: false,
70
76
  })
71
77
 
72
- const emit = defineEmits<{ copy: [title: string] }>()
78
+ const emit = defineEmits<{ copy: [title: string], edit: [] }>()
73
79
 
74
80
  /** Etat deplie, liable en `v-model` quand le panneau veut le piloter. */
75
81
  const open = defineModel<boolean>({ default: false })
@@ -86,6 +92,7 @@ const WORDS = {
86
92
  collapse: 'Replier la source',
87
93
  copy: 'Copier le titre',
88
94
  copied: 'Titre copié',
95
+ edit: 'Modifier la source',
89
96
  sectors: {
90
97
  'public': 'Public',
91
98
  'private': 'Privé',
@@ -111,6 +118,7 @@ const WORDS = {
111
118
  collapse: 'Collapse source',
112
119
  copy: 'Copy title',
113
120
  copied: 'Title copied',
121
+ edit: 'Edit source',
114
122
  sectors: {
115
123
  'public': 'Public',
116
124
  'private': 'Private',
@@ -252,6 +260,16 @@ onUnmounted(() => clearTimeout(copyTimer))
252
260
  </button>
253
261
 
254
262
  <div class="rm-src__actions">
263
+ <button
264
+ v-if="editable"
265
+ type="button"
266
+ class="rm-src__btn"
267
+ :aria-label="t.edit"
268
+ :title="t.edit"
269
+ @click="emit('edit')"
270
+ >
271
+ <RmIcon name="PencilSimple" :size="14" />
272
+ </button>
255
273
  <button
256
274
  v-if="copyable"
257
275
  type="button"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekkare/romulus",
3
- "version": "0.1.169",
3
+ "version": "0.1.171",
4
4
  "description": "Romulus Design System - Nuxt module by Tekkare",
5
5
  "license": "MIT",
6
6
  "type": "module",