@tekkare/romulus 0.1.170 → 0.1.172
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,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 (`é`), 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>
|
|
@@ -43,12 +43,12 @@ withDefaults(
|
|
|
43
43
|
il ne doit pas couper ce que le lecteur d'ecran est en train de dire. -->
|
|
44
44
|
<div v-if="visible" class="rm-snackbar" role="status" aria-live="polite">
|
|
45
45
|
<div v-if="loading" class="rm-snackbar__spinner" />
|
|
46
|
-
<RmIcon v-else-if="icon" :name="icon" :size="
|
|
46
|
+
<RmIcon v-else-if="icon" :name="icon" :size="24" :color="iconColor" />
|
|
47
47
|
<p class="rm-snackbar__text">{{ text }}</p>
|
|
48
48
|
</div>
|
|
49
49
|
</Transition>
|
|
50
50
|
</template>
|
|
51
51
|
|
|
52
52
|
<style scoped>
|
|
53
|
-
.rm-snackbar{align-items:center;background
|
|
53
|
+
.rm-snackbar{align-items:center;background:var(--color-primary);border-radius:var(--radius-2xl);bottom:var(--space-24);box-shadow:var(--shadow-lg);display:flex;gap:var(--space-16);left:0;margin:0 auto;max-width:min(90vw,620px);padding:var(--space-16) var(--space-32);position:fixed;right:0;width:-moz-fit-content;width:fit-content;z-index:var(--z-toast)}:global(body.night) .rm-snackbar{background:color-mix(in srgb,var(--color-primary) 55%,#0e0e18)}.rm-snackbar__text{color:var(--color-text-inverse);font-family:var(--font-sans);font-size:var(--font-16);font-weight:var(--weight-medium);line-height:1.5;margin:0}.rm-snackbar__spinner{animation:rm-snackbar-spin .9s linear infinite;border:2px solid hsla(0,0%,100%,.25);border-radius:50%;border-top:2px solid var(--color-text-inverse);flex:none;height:24px;width:24px}@keyframes rm-snackbar-spin{to{transform:rotate(1turn)}}.rm-snackbar-enter-active,.rm-snackbar-leave-active{transition:transform var(--duration-base) cubic-bezier(.25,.46,.45,.94),opacity var(--duration-base) linear}.rm-snackbar-enter-from,.rm-snackbar-leave-to{opacity:0;transform:translateY(calc(100% + var(--space-16)))}@media (prefers-reduced-motion:reduce){.rm-snackbar-enter-active,.rm-snackbar-leave-active{transition:opacity var(--duration-fast) linear}.rm-snackbar-enter-from,.rm-snackbar-leave-to{transform:none}.rm-snackbar__spinner{animation-duration:3s}}
|
|
54
54
|
</style>
|