@nuxt-customer-portal/ui 0.4.8 → 0.5.0
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/CHANGELOG.md +16 -0
- package/README.md +16 -0
- package/app/components/PortalMarkdownEditor.vue +373 -0
- package/app/utils/protected-markdown.ts +70 -0
- package/app/utils/visual-markdown.ts +121 -0
- package/i18n/locales/en.json +26 -0
- package/i18n/locales/nl.json +26 -0
- package/nuxt.config.ts +7 -1
- package/package.json +13 -2
- package/portal.manifest.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# @nuxt-customer-portal/ui
|
|
2
2
|
|
|
3
|
+
## 0.5.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 5beff82: Add a shared visual Markdown editor with a raw Markdown mode, protected special content, and English/Dutch formatting controls. Use it for product summaries/descriptions and email bodies/footers, including a picker for the selected email's available placeholders.
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- @nuxt-customer-portal/core@0.5.0
|
|
12
|
+
|
|
13
|
+
## 0.4.9
|
|
14
|
+
|
|
15
|
+
### Patch Changes
|
|
16
|
+
|
|
17
|
+
- @nuxt-customer-portal/core@0.4.9
|
|
18
|
+
|
|
3
19
|
## 0.4.8
|
|
4
20
|
|
|
5
21
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -3,3 +3,19 @@
|
|
|
3
3
|
Neutral shell primitives and fallback layouts for Nuxt Customer Portal. Hosts
|
|
4
4
|
can override every layout and branded component while retaining the shared
|
|
5
5
|
feature registry, session state, and navigation contracts from core.
|
|
6
|
+
|
|
7
|
+
`PortalMarkdownEditor` provides visual formatting and a raw Markdown mode over
|
|
8
|
+
one string `v-model`. It integrates with Nuxt UI `UFormField` validation and
|
|
9
|
+
inherits the form's disabled state. Use it for fields rendered as Markdown;
|
|
10
|
+
plain text fields should retain their text inputs. Product summaries and
|
|
11
|
+
descriptions and email bodies and footers use this component.
|
|
12
|
+
|
|
13
|
+
Pass `placeholders` as an array of `{ label, value }` options to offer insertion
|
|
14
|
+
at the current cursor in either mode. Email forms derive these options from the
|
|
15
|
+
selected email definition. Supported visual formatting includes headings,
|
|
16
|
+
bold, italic, links, lists, quotes, code and dividers. HTML, images, tables,
|
|
17
|
+
checklists, reference links and other unsupported syntax are shown as inert,
|
|
18
|
+
protected source blocks; edit these through Markdown mode. Opening the editor
|
|
19
|
+
or changing modes does not rewrite the stored string. Visual edits serialize
|
|
20
|
+
standard Markdown formatting while retaining protected blocks. Existing
|
|
21
|
+
server renderers still determine what appears in published content.
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import type { Editor } from '@tiptap/core'
|
|
4
|
+
import { EditorContent, useEditor } from '@tiptap/vue-3'
|
|
5
|
+
import StarterKit from '@tiptap/starter-kit'
|
|
6
|
+
import { Markdown } from '@tiptap/markdown'
|
|
7
|
+
import { useFormField, inputIdInjectionKey } from '#ui/composables/useFormField'
|
|
8
|
+
import { parseVisualMarkdown } from '../utils/visual-markdown'
|
|
9
|
+
import { createProtectedMarkdown, insertMarkdownPlaceholder, MarkdownPlaceholder } from '../utils/protected-markdown'
|
|
10
|
+
|
|
11
|
+
const props = withDefaults(
|
|
12
|
+
defineProps<{
|
|
13
|
+
modelValue: string
|
|
14
|
+
disabled?: boolean
|
|
15
|
+
rows?: number
|
|
16
|
+
placeholder?: string
|
|
17
|
+
placeholders?: Array<{ label: string; value: string }>
|
|
18
|
+
}>(),
|
|
19
|
+
{ rows: 8, placeholders: () => [] }
|
|
20
|
+
)
|
|
21
|
+
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
|
22
|
+
const { t } = useI18n()
|
|
23
|
+
const source = ref(false)
|
|
24
|
+
const sourceElement = ref<{ textareaRef?: HTMLTextAreaElement }>()
|
|
25
|
+
const { id, disabled, color, ariaAttrs, emitFormInput, emitFormBlur, emitFormFocus } = useFormField(props)
|
|
26
|
+
// Toolbar controls must not inherit or replace the Markdown field's input ID.
|
|
27
|
+
provide(inputIdInjectionKey, ref(undefined))
|
|
28
|
+
const pickerId = useId()
|
|
29
|
+
const notice = ref(false)
|
|
30
|
+
let syncing = false
|
|
31
|
+
function update(value: string) {
|
|
32
|
+
emit('update:modelValue', value)
|
|
33
|
+
emitFormInput()
|
|
34
|
+
}
|
|
35
|
+
const protectedMarkdown = createProtectedMarkdown(
|
|
36
|
+
() => syncing,
|
|
37
|
+
() => {
|
|
38
|
+
notice.value = true
|
|
39
|
+
}
|
|
40
|
+
)
|
|
41
|
+
const editor = useEditor({
|
|
42
|
+
extensions: [
|
|
43
|
+
StarterKit.configure({ underline: false, strike: false, link: { openOnClick: false } }),
|
|
44
|
+
Markdown,
|
|
45
|
+
protectedMarkdown,
|
|
46
|
+
MarkdownPlaceholder
|
|
47
|
+
],
|
|
48
|
+
content: parseVisualMarkdown(props.modelValue),
|
|
49
|
+
editable: !disabled.value,
|
|
50
|
+
editorProps: { attributes: { role: 'textbox', 'aria-multiline': 'true', class: 'p-3 outline-none min-h-32' } },
|
|
51
|
+
onCreate: ({ editor }) => {
|
|
52
|
+
editor.markdown!.parse = parseVisualMarkdown
|
|
53
|
+
editor.setOptions({
|
|
54
|
+
editorProps: {
|
|
55
|
+
attributes: {
|
|
56
|
+
id: id.value || '',
|
|
57
|
+
...ariaAttrs.value,
|
|
58
|
+
role: 'textbox',
|
|
59
|
+
'aria-multiline': 'true',
|
|
60
|
+
class: 'p-3 outline-none min-h-32'
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
})
|
|
64
|
+
},
|
|
65
|
+
onUpdate: ({ editor }) => {
|
|
66
|
+
if (!syncing) {
|
|
67
|
+
update(editor.getMarkdown())
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
onFocus: emitFormFocus,
|
|
71
|
+
onBlur: emitFormBlur
|
|
72
|
+
})
|
|
73
|
+
function syncContent() {
|
|
74
|
+
if (!editor.value) {
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
syncing = true
|
|
78
|
+
try {
|
|
79
|
+
editor.value.commands.setContent(parseVisualMarkdown(props.modelValue), { emitUpdate: false })
|
|
80
|
+
} finally {
|
|
81
|
+
syncing = false
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
watch(
|
|
85
|
+
() => props.modelValue,
|
|
86
|
+
(value) => {
|
|
87
|
+
if (!source.value && editor.value?.getMarkdown() !== value) {
|
|
88
|
+
syncContent()
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
)
|
|
92
|
+
watch(source, (raw) => {
|
|
93
|
+
notice.value = false
|
|
94
|
+
if (!raw) {
|
|
95
|
+
syncContent()
|
|
96
|
+
}
|
|
97
|
+
})
|
|
98
|
+
watch(disabled, (value) => editor.value?.setEditable(!value))
|
|
99
|
+
watch(
|
|
100
|
+
[id, ariaAttrs],
|
|
101
|
+
() =>
|
|
102
|
+
editor.value?.setOptions({
|
|
103
|
+
editorProps: {
|
|
104
|
+
attributes: {
|
|
105
|
+
id: id.value || '',
|
|
106
|
+
...ariaAttrs.value,
|
|
107
|
+
role: 'textbox',
|
|
108
|
+
'aria-multiline': 'true',
|
|
109
|
+
class: 'p-3 outline-none min-h-32'
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}),
|
|
113
|
+
{ immediate: true }
|
|
114
|
+
)
|
|
115
|
+
const toolbarItems = computed(() => [
|
|
116
|
+
[
|
|
117
|
+
{ kind: 'undo' as const, icon: 'i-lucide-undo-2', tooltip: { text: t('portalMarkdown.undo') } },
|
|
118
|
+
{ kind: 'redo' as const, icon: 'i-lucide-redo-2', tooltip: { text: t('portalMarkdown.redo') } }
|
|
119
|
+
],
|
|
120
|
+
[
|
|
121
|
+
{
|
|
122
|
+
kind: 'mark' as const,
|
|
123
|
+
mark: 'bold' as const,
|
|
124
|
+
icon: 'i-lucide-bold',
|
|
125
|
+
tooltip: { text: t('portalMarkdown.bold') }
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
kind: 'mark' as const,
|
|
129
|
+
mark: 'italic' as const,
|
|
130
|
+
icon: 'i-lucide-italic',
|
|
131
|
+
tooltip: { text: t('portalMarkdown.italic') }
|
|
132
|
+
},
|
|
133
|
+
{ kind: 'mark' as const, mark: 'code' as const, icon: 'i-lucide-code', tooltip: { text: t('portalMarkdown.code') } }
|
|
134
|
+
],
|
|
135
|
+
[
|
|
136
|
+
{
|
|
137
|
+
icon: 'i-lucide-heading',
|
|
138
|
+
'aria-label': t('portalMarkdown.heading'),
|
|
139
|
+
items: [
|
|
140
|
+
{ kind: 'paragraph' as const, label: t('portalMarkdown.paragraph') },
|
|
141
|
+
...([1, 2, 3, 4, 5, 6] as const).map((level) => ({
|
|
142
|
+
kind: 'heading' as const,
|
|
143
|
+
level,
|
|
144
|
+
label: t('portalMarkdown.headingLevel', { level })
|
|
145
|
+
}))
|
|
146
|
+
]
|
|
147
|
+
}
|
|
148
|
+
],
|
|
149
|
+
...(['bulletList', 'orderedList', 'blockquote', 'codeBlock', 'horizontalRule'] as const).map((kind, index) => [
|
|
150
|
+
{
|
|
151
|
+
kind,
|
|
152
|
+
icon: ['i-lucide-list', 'i-lucide-list-ordered', 'i-lucide-quote', 'i-lucide-square-code', 'i-lucide-minus'][
|
|
153
|
+
index
|
|
154
|
+
],
|
|
155
|
+
tooltip: { text: t(`portalMarkdown.${kind}`) }
|
|
156
|
+
}
|
|
157
|
+
])
|
|
158
|
+
])
|
|
159
|
+
const toolbar = computed(() =>
|
|
160
|
+
toolbarItems.value.map((group) =>
|
|
161
|
+
group.map((item) => ({
|
|
162
|
+
...item,
|
|
163
|
+
'aria-label': 'tooltip' in item ? item.tooltip.text : t('portalMarkdown.heading')
|
|
164
|
+
}))
|
|
165
|
+
)
|
|
166
|
+
)
|
|
167
|
+
const hasProtected = computed(() => {
|
|
168
|
+
return (
|
|
169
|
+
props.modelValue && parseVisualMarkdown(props.modelValue).content?.some((node) => node.type === 'protectedMarkdown')
|
|
170
|
+
)
|
|
171
|
+
})
|
|
172
|
+
const selectedPlaceholder = ref<string>()
|
|
173
|
+
function insertPlaceholder(value: string) {
|
|
174
|
+
if (disabled.value) {
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
if (source.value) {
|
|
178
|
+
const textarea = sourceElement.value?.textareaRef
|
|
179
|
+
const start = textarea?.selectionStart ?? props.modelValue.length
|
|
180
|
+
const end = textarea?.selectionEnd ?? start
|
|
181
|
+
const inserted = insertMarkdownPlaceholder(props.modelValue, value, start, end)
|
|
182
|
+
update(inserted.value)
|
|
183
|
+
nextTick(() => {
|
|
184
|
+
textarea?.focus()
|
|
185
|
+
textarea?.setSelectionRange(inserted.cursor, inserted.cursor)
|
|
186
|
+
})
|
|
187
|
+
} else {
|
|
188
|
+
editor.value
|
|
189
|
+
?.chain()
|
|
190
|
+
.focus()
|
|
191
|
+
.insertContent(
|
|
192
|
+
editor.value.isActive('codeBlock')
|
|
193
|
+
? { type: 'text', text: value }
|
|
194
|
+
: { type: 'markdownPlaceholder', attrs: { raw: value } }
|
|
195
|
+
)
|
|
196
|
+
.run()
|
|
197
|
+
}
|
|
198
|
+
nextTick(() => {
|
|
199
|
+
selectedPlaceholder.value = undefined
|
|
200
|
+
})
|
|
201
|
+
}
|
|
202
|
+
const linkOpen = ref(false)
|
|
203
|
+
const linkState = reactive({ url: '' })
|
|
204
|
+
// Link validation is shared with the server's allowed Markdown link schemes.
|
|
205
|
+
const linkSchema = z.object({
|
|
206
|
+
url: z
|
|
207
|
+
.string()
|
|
208
|
+
.trim()
|
|
209
|
+
.regex(/^(https?:\/\/|mailto:|\/|#)/i, t('portalMarkdown.invalidLink'))
|
|
210
|
+
})
|
|
211
|
+
function applyLink() {
|
|
212
|
+
if (disabled.value) {
|
|
213
|
+
return
|
|
214
|
+
}
|
|
215
|
+
editor.value?.chain().focus().extendMarkRange('link').setLink({ href: linkState.url.trim() }).run()
|
|
216
|
+
linkOpen.value = false
|
|
217
|
+
}
|
|
218
|
+
function removeLink() {
|
|
219
|
+
if (disabled.value) {
|
|
220
|
+
return
|
|
221
|
+
}
|
|
222
|
+
editor.value?.chain().focus().unsetLink().run()
|
|
223
|
+
linkOpen.value = false
|
|
224
|
+
}
|
|
225
|
+
function openLink(instance: Editor) {
|
|
226
|
+
linkState.url = instance.getAttributes('link').href || ''
|
|
227
|
+
linkOpen.value = true
|
|
228
|
+
}
|
|
229
|
+
onBeforeUnmount(() => editor.value?.destroy())
|
|
230
|
+
</script>
|
|
231
|
+
|
|
232
|
+
<template>
|
|
233
|
+
<div class="w-full rounded-md border overflow-hidden" :class="color === 'error' ? 'border-error' : 'border-default'">
|
|
234
|
+
<div class="flex flex-wrap items-center gap-2 border-b border-default p-2">
|
|
235
|
+
<UButton
|
|
236
|
+
type="button"
|
|
237
|
+
color="neutral"
|
|
238
|
+
:variant="!source ? 'soft' : 'ghost'"
|
|
239
|
+
:aria-pressed="!source"
|
|
240
|
+
@click="source = false"
|
|
241
|
+
>{{ t('portalMarkdown.visual') }}</UButton
|
|
242
|
+
>
|
|
243
|
+
<UButton
|
|
244
|
+
type="button"
|
|
245
|
+
color="neutral"
|
|
246
|
+
:variant="source ? 'soft' : 'ghost'"
|
|
247
|
+
:aria-pressed="source"
|
|
248
|
+
@click="source = true"
|
|
249
|
+
>{{ t('portalMarkdown.source') }}</UButton
|
|
250
|
+
>
|
|
251
|
+
<USelect
|
|
252
|
+
v-if="placeholders.length"
|
|
253
|
+
:id="pickerId"
|
|
254
|
+
v-model="selectedPlaceholder"
|
|
255
|
+
:items="placeholders"
|
|
256
|
+
:placeholder="t('portalMarkdown.insertPlaceholder')"
|
|
257
|
+
:aria-label="t('portalMarkdown.insertPlaceholder')"
|
|
258
|
+
:disabled="disabled"
|
|
259
|
+
@update:model-value="insertPlaceholder(String($event))"
|
|
260
|
+
/>
|
|
261
|
+
</div>
|
|
262
|
+
<UTextarea
|
|
263
|
+
v-if="source"
|
|
264
|
+
:id="id"
|
|
265
|
+
ref="sourceElement"
|
|
266
|
+
v-bind="ariaAttrs"
|
|
267
|
+
:model-value="modelValue"
|
|
268
|
+
:rows="rows"
|
|
269
|
+
:disabled="disabled"
|
|
270
|
+
:placeholder="placeholder"
|
|
271
|
+
class="w-full font-mono"
|
|
272
|
+
:ui="{ base: 'rounded-none ring-0' }"
|
|
273
|
+
@update:model-value="update(String($event))"
|
|
274
|
+
@focus="emitFormFocus"
|
|
275
|
+
@blur="emitFormBlur"
|
|
276
|
+
/>
|
|
277
|
+
<ClientOnly v-else>
|
|
278
|
+
<div v-if="editor">
|
|
279
|
+
<div v-if="!disabled" class="flex flex-wrap items-center border-b border-default p-1">
|
|
280
|
+
<UEditorToolbar :editor="editor" :items="toolbar" class="flex-wrap" />
|
|
281
|
+
<UButton
|
|
282
|
+
type="button"
|
|
283
|
+
icon="i-lucide-link"
|
|
284
|
+
color="neutral"
|
|
285
|
+
variant="ghost"
|
|
286
|
+
:aria-label="t('portalMarkdown.link')"
|
|
287
|
+
@click="openLink(editor)"
|
|
288
|
+
/>
|
|
289
|
+
</div>
|
|
290
|
+
<p v-if="hasProtected || notice" class="px-3 pt-2 text-xs text-muted" role="status">
|
|
291
|
+
{{ t('portalMarkdown.protected') }}
|
|
292
|
+
</p>
|
|
293
|
+
<EditorContent :editor="editor" class="portal-markdown-content" :style="{ minHeight: `${rows * 1.5}rem` }" />
|
|
294
|
+
</div>
|
|
295
|
+
<template #fallback
|
|
296
|
+
><div class="p-3 whitespace-pre-wrap">{{ modelValue }}</div></template
|
|
297
|
+
>
|
|
298
|
+
</ClientOnly>
|
|
299
|
+
<UModal v-if="linkOpen" v-model:open="linkOpen" :title="t('portalMarkdown.link')">
|
|
300
|
+
<template #body>
|
|
301
|
+
<UForm :state="linkState" :schema="linkSchema" novalidate class="space-y-4" @submit="applyLink">
|
|
302
|
+
<UFormField name="url" :label="t('portalMarkdown.linkAddress')"
|
|
303
|
+
><UInput v-model="linkState.url" autofocus class="w-full"
|
|
304
|
+
/></UFormField>
|
|
305
|
+
<div class="flex gap-2">
|
|
306
|
+
<UButton type="submit">{{ t('portalMarkdown.applyLink') }}</UButton>
|
|
307
|
+
<UButton type="button" color="neutral" variant="outline" @click="removeLink">{{
|
|
308
|
+
t('portalMarkdown.removeLink')
|
|
309
|
+
}}</UButton>
|
|
310
|
+
</div>
|
|
311
|
+
</UForm>
|
|
312
|
+
</template>
|
|
313
|
+
</UModal>
|
|
314
|
+
</div>
|
|
315
|
+
</template>
|
|
316
|
+
|
|
317
|
+
<style scoped>
|
|
318
|
+
.portal-markdown-content :deep(p),
|
|
319
|
+
.portal-markdown-content :deep(ul),
|
|
320
|
+
.portal-markdown-content :deep(ol),
|
|
321
|
+
.portal-markdown-content :deep(pre),
|
|
322
|
+
.portal-markdown-content :deep(blockquote) {
|
|
323
|
+
margin-block: 0.6em;
|
|
324
|
+
}
|
|
325
|
+
.portal-markdown-content :deep(ul) {
|
|
326
|
+
list-style: disc;
|
|
327
|
+
padding-left: 1.5rem;
|
|
328
|
+
}
|
|
329
|
+
.portal-markdown-content :deep(ol) {
|
|
330
|
+
list-style: decimal;
|
|
331
|
+
padding-left: 1.5rem;
|
|
332
|
+
}
|
|
333
|
+
.portal-markdown-content :deep(h1),
|
|
334
|
+
.portal-markdown-content :deep(h2),
|
|
335
|
+
.portal-markdown-content :deep(h3),
|
|
336
|
+
.portal-markdown-content :deep(h4),
|
|
337
|
+
.portal-markdown-content :deep(h5),
|
|
338
|
+
.portal-markdown-content :deep(h6) {
|
|
339
|
+
font-weight: 600;
|
|
340
|
+
margin-block: 0.8em 0.4em;
|
|
341
|
+
}
|
|
342
|
+
.portal-markdown-content :deep(h1) {
|
|
343
|
+
font-size: 1.8em;
|
|
344
|
+
}
|
|
345
|
+
.portal-markdown-content :deep(h2) {
|
|
346
|
+
font-size: 1.5em;
|
|
347
|
+
}
|
|
348
|
+
.portal-markdown-content :deep(h3) {
|
|
349
|
+
font-size: 1.25em;
|
|
350
|
+
}
|
|
351
|
+
.portal-markdown-content :deep(a) {
|
|
352
|
+
color: var(--ui-primary);
|
|
353
|
+
text-decoration: underline;
|
|
354
|
+
}
|
|
355
|
+
.portal-markdown-content :deep(blockquote) {
|
|
356
|
+
border-left: 3px solid var(--ui-border);
|
|
357
|
+
padding-left: 1rem;
|
|
358
|
+
}
|
|
359
|
+
.portal-markdown-content :deep(pre) {
|
|
360
|
+
background: var(--ui-bg-muted);
|
|
361
|
+
padding: 0.75rem;
|
|
362
|
+
white-space: pre-wrap;
|
|
363
|
+
overflow-wrap: anywhere;
|
|
364
|
+
}
|
|
365
|
+
.portal-markdown-content :deep(code) {
|
|
366
|
+
font-family: monospace;
|
|
367
|
+
background: var(--ui-bg-muted);
|
|
368
|
+
}
|
|
369
|
+
.portal-markdown-content :deep(hr) {
|
|
370
|
+
margin-block: 1rem;
|
|
371
|
+
border-color: var(--ui-border);
|
|
372
|
+
}
|
|
373
|
+
</style>
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Node, nodeInputRule } from '@tiptap/core'
|
|
2
|
+
import { Plugin } from '@tiptap/pm/state'
|
|
3
|
+
|
|
4
|
+
export function createProtectedMarkdown(isSyncing: () => boolean, onBlocked: () => void) {
|
|
5
|
+
return Node.create({
|
|
6
|
+
name: 'protectedMarkdown',
|
|
7
|
+
group: 'block',
|
|
8
|
+
atom: true,
|
|
9
|
+
addAttributes: () => ({ raw: { default: '' } }),
|
|
10
|
+
parseHTML: () => [{ tag: 'pre[data-protected-markdown]', getAttrs: (element) => ({ raw: element.textContent }) }],
|
|
11
|
+
renderHTML: ({ node }) => ['pre', { 'data-protected-markdown': '' }, node.attrs.raw],
|
|
12
|
+
renderMarkdown: (node) => node.attrs?.raw || '',
|
|
13
|
+
addProseMirrorPlugins: () => [
|
|
14
|
+
new Plugin({
|
|
15
|
+
filterTransaction(transaction, state) {
|
|
16
|
+
if (!transaction.docChanged || isSyncing()) {
|
|
17
|
+
return true
|
|
18
|
+
}
|
|
19
|
+
const blocks = (doc: typeof state.doc) => {
|
|
20
|
+
const counts = new Map<string, number>()
|
|
21
|
+
doc.descendants((node) => {
|
|
22
|
+
if (node.type.name === 'protectedMarkdown') {
|
|
23
|
+
counts.set(node.attrs.raw, (counts.get(node.attrs.raw) || 0) + 1)
|
|
24
|
+
}
|
|
25
|
+
})
|
|
26
|
+
return counts
|
|
27
|
+
}
|
|
28
|
+
const after = blocks(transaction.doc)
|
|
29
|
+
for (const [raw, count] of blocks(state.doc)) {
|
|
30
|
+
if ((after.get(raw) || 0) < count) {
|
|
31
|
+
onBlocked()
|
|
32
|
+
return false
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return true
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
]
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function insertMarkdownPlaceholder(source: string, value: string, start = source.length, end = start) {
|
|
43
|
+
return { value: source.slice(0, start) + value + source.slice(end), cursor: start + value.length }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Tokens must serialize literally: ordinary Markdown text escapes underscores,
|
|
47
|
+
// which would stop the email renderer from recognizing placeholder names.
|
|
48
|
+
export const MarkdownPlaceholder = Node.create({
|
|
49
|
+
name: 'markdownPlaceholder',
|
|
50
|
+
group: 'inline',
|
|
51
|
+
inline: true,
|
|
52
|
+
atom: true,
|
|
53
|
+
addAttributes: () => ({ raw: { default: '' } }),
|
|
54
|
+
parseHTML: () => [{ tag: 'span[data-markdown-placeholder]', getAttrs: (element) => ({ raw: element.textContent }) }],
|
|
55
|
+
renderHTML: ({ node }) => [
|
|
56
|
+
'span',
|
|
57
|
+
{ 'data-markdown-placeholder': '', class: 'rounded bg-elevated px-1 font-mono text-sm' },
|
|
58
|
+
node.attrs.raw
|
|
59
|
+
],
|
|
60
|
+
renderMarkdown: (node) => node.attrs?.raw || '',
|
|
61
|
+
addInputRules() {
|
|
62
|
+
return [
|
|
63
|
+
nodeInputRule({
|
|
64
|
+
find: /({{\s*[a-z0-9_]+\s*}})$/i,
|
|
65
|
+
type: this.type,
|
|
66
|
+
getAttributes: (match) => ({ raw: match[1] })
|
|
67
|
+
})
|
|
68
|
+
]
|
|
69
|
+
}
|
|
70
|
+
})
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { unified } from 'unified'
|
|
2
|
+
import remarkParse from 'remark-parse'
|
|
3
|
+
import remarkGfm from 'remark-gfm'
|
|
4
|
+
import type { JSONContent } from '@tiptap/core'
|
|
5
|
+
|
|
6
|
+
interface MarkdownNode {
|
|
7
|
+
type: string
|
|
8
|
+
value?: string
|
|
9
|
+
children?: MarkdownNode[]
|
|
10
|
+
depth?: number
|
|
11
|
+
url?: string
|
|
12
|
+
title?: string
|
|
13
|
+
lang?: string
|
|
14
|
+
meta?: string
|
|
15
|
+
ordered?: boolean
|
|
16
|
+
start?: number
|
|
17
|
+
checked?: boolean | null
|
|
18
|
+
position?: { start: { offset: number }; end: { offset: number } }
|
|
19
|
+
}
|
|
20
|
+
const parser = unified().use(remarkParse).use(remarkGfm)
|
|
21
|
+
const supported = new Set([
|
|
22
|
+
'paragraph',
|
|
23
|
+
'text',
|
|
24
|
+
'strong',
|
|
25
|
+
'emphasis',
|
|
26
|
+
'link',
|
|
27
|
+
'heading',
|
|
28
|
+
'blockquote',
|
|
29
|
+
'list',
|
|
30
|
+
'listItem',
|
|
31
|
+
'code',
|
|
32
|
+
'inlineCode',
|
|
33
|
+
'break',
|
|
34
|
+
'thematicBreak'
|
|
35
|
+
])
|
|
36
|
+
function unsupported(node: MarkdownNode): boolean {
|
|
37
|
+
return (
|
|
38
|
+
!supported.has(node.type) ||
|
|
39
|
+
(node.type === 'listItem' && node.children?.[0]?.type !== 'paragraph') ||
|
|
40
|
+
Boolean(node.title || node.meta || node.checked != null) ||
|
|
41
|
+
(node.children || []).some(unsupported)
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
export function placeholderText(text: string, marks: NonNullable<JSONContent['marks']> = []): JSONContent[] {
|
|
45
|
+
return text
|
|
46
|
+
.split(/({{\s*[a-z0-9_]+\s*}})/gi)
|
|
47
|
+
.filter(Boolean)
|
|
48
|
+
.map((part) =>
|
|
49
|
+
/^{{\s*[a-z0-9_]+\s*}}$/i.test(part)
|
|
50
|
+
? { type: 'markdownPlaceholder', attrs: { raw: part }, marks }
|
|
51
|
+
: { type: 'text', text: part, marks }
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
function inline(nodes: MarkdownNode[], marks: NonNullable<JSONContent['marks']> = []): JSONContent[] {
|
|
55
|
+
return nodes.flatMap((node) => {
|
|
56
|
+
const mark = ({ strong: 'bold', emphasis: 'italic', link: 'link' } as Record<string, string>)[node.type]
|
|
57
|
+
if (mark) {
|
|
58
|
+
return inline(node.children || [], [
|
|
59
|
+
...marks,
|
|
60
|
+
{ type: mark, ...(node.type === 'link' ? { attrs: { href: node.url } } : {}) }
|
|
61
|
+
])
|
|
62
|
+
}
|
|
63
|
+
if (node.type === 'break') {
|
|
64
|
+
return [{ type: 'hardBreak' }]
|
|
65
|
+
}
|
|
66
|
+
return node.value
|
|
67
|
+
? node.type === 'inlineCode'
|
|
68
|
+
? [{ type: 'text', text: node.value, marks: [...marks, { type: 'code' }] }]
|
|
69
|
+
: placeholderText(node.value, marks)
|
|
70
|
+
: []
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
function block(node: MarkdownNode): JSONContent {
|
|
74
|
+
const content = () => (node.children || []).map(block)
|
|
75
|
+
switch (node.type) {
|
|
76
|
+
case 'paragraph':
|
|
77
|
+
return { type: 'paragraph', content: inline(node.children || []) }
|
|
78
|
+
case 'heading':
|
|
79
|
+
return { type: 'heading', attrs: { level: node.depth }, content: inline(node.children || []) }
|
|
80
|
+
case 'blockquote':
|
|
81
|
+
return { type: 'blockquote', content: content() }
|
|
82
|
+
case 'thematicBreak':
|
|
83
|
+
return { type: 'horizontalRule' }
|
|
84
|
+
case 'list':
|
|
85
|
+
return {
|
|
86
|
+
type: node.ordered ? 'orderedList' : 'bulletList',
|
|
87
|
+
attrs: { start: node.start || 1 },
|
|
88
|
+
content: content()
|
|
89
|
+
}
|
|
90
|
+
case 'listItem':
|
|
91
|
+
return { type: 'listItem', content: content() }
|
|
92
|
+
case 'code':
|
|
93
|
+
return {
|
|
94
|
+
type: 'codeBlock',
|
|
95
|
+
attrs: { language: node.lang },
|
|
96
|
+
content: node.value ? [{ type: 'text', text: node.value }] : []
|
|
97
|
+
}
|
|
98
|
+
default:
|
|
99
|
+
throw new Error(`Unsupported Markdown: ${node.type}`)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// Adapted from Marpos Studio: preserve unsupported blocks as inert source.
|
|
103
|
+
// HTML, tables, images, reference links and checklists remain editable in source mode.
|
|
104
|
+
export function parseVisualMarkdown(source: string): JSONContent {
|
|
105
|
+
try {
|
|
106
|
+
const tree = parser.parse(source) as unknown as MarkdownNode
|
|
107
|
+
return {
|
|
108
|
+
type: 'doc',
|
|
109
|
+
content: (tree.children || []).map((node) =>
|
|
110
|
+
unsupported(node)
|
|
111
|
+
? {
|
|
112
|
+
type: 'protectedMarkdown',
|
|
113
|
+
attrs: { raw: source.slice(node.position!.start.offset, node.position!.end.offset) }
|
|
114
|
+
}
|
|
115
|
+
: block(node)
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
} catch {
|
|
119
|
+
return { type: 'doc', content: [{ type: 'protectedMarkdown', attrs: { raw: source } }] }
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"portalMarkdown": {
|
|
3
|
+
"visual": "Visual editor",
|
|
4
|
+
"source": "Markdown",
|
|
5
|
+
"insertPlaceholder": "Insert placeholder",
|
|
6
|
+
"undo": "Undo",
|
|
7
|
+
"redo": "Redo",
|
|
8
|
+
"bold": "Bold",
|
|
9
|
+
"italic": "Italic",
|
|
10
|
+
"code": "Inline code",
|
|
11
|
+
"heading": "Text style",
|
|
12
|
+
"paragraph": "Paragraph",
|
|
13
|
+
"headingLevel": "Heading {level}",
|
|
14
|
+
"bulletList": "Bullet list",
|
|
15
|
+
"orderedList": "Numbered list",
|
|
16
|
+
"blockquote": "Quote",
|
|
17
|
+
"codeBlock": "Code block",
|
|
18
|
+
"horizontalRule": "Divider",
|
|
19
|
+
"protected": "Special content is preserved. Use Markdown mode to edit or remove those blocks.",
|
|
20
|
+
"link": "Add or edit link",
|
|
21
|
+
"linkAddress": "Link address",
|
|
22
|
+
"invalidLink": "Use a website address, email link or page path.",
|
|
23
|
+
"applyLink": "Apply link",
|
|
24
|
+
"removeLink": "Remove link"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"portalMarkdown": {
|
|
3
|
+
"visual": "Visuele editor",
|
|
4
|
+
"source": "Markdown",
|
|
5
|
+
"insertPlaceholder": "Placeholder invoegen",
|
|
6
|
+
"undo": "Ongedaan maken",
|
|
7
|
+
"redo": "Opnieuw",
|
|
8
|
+
"bold": "Vet",
|
|
9
|
+
"italic": "Cursief",
|
|
10
|
+
"code": "Inline code",
|
|
11
|
+
"heading": "Tekststijl",
|
|
12
|
+
"paragraph": "Alinea",
|
|
13
|
+
"headingLevel": "Kop {level}",
|
|
14
|
+
"bulletList": "Opsomming",
|
|
15
|
+
"orderedList": "Genummerde lijst",
|
|
16
|
+
"blockquote": "Citaat",
|
|
17
|
+
"codeBlock": "Codeblok",
|
|
18
|
+
"horizontalRule": "Scheidingslijn",
|
|
19
|
+
"protected": "Speciale inhoud blijft behouden. Gebruik de Markdown-modus om deze blokken te wijzigen of verwijderen.",
|
|
20
|
+
"link": "Link toevoegen of bewerken",
|
|
21
|
+
"linkAddress": "Linkadres",
|
|
22
|
+
"invalidLink": "Gebruik een websiteadres, e-maillink of paginapad.",
|
|
23
|
+
"applyLink": "Link toepassen",
|
|
24
|
+
"removeLink": "Link verwijderen"
|
|
25
|
+
}
|
|
26
|
+
}
|
package/nuxt.config.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
export default defineNuxtConfig({
|
|
2
2
|
$meta: { name: 'nuxt-customer-portal-ui' },
|
|
3
3
|
compatibilityDate: '2025-10-24',
|
|
4
|
-
modules: ['@nuxt/ui', '@nuxtjs/i18n', '@pinia/nuxt']
|
|
4
|
+
modules: ['@nuxt/ui', '@nuxtjs/i18n', '@pinia/nuxt'],
|
|
5
|
+
i18n: {
|
|
6
|
+
locales: [
|
|
7
|
+
{ code: 'en', file: 'en.json' },
|
|
8
|
+
{ code: 'nl', file: 'nl.json' }
|
|
9
|
+
]
|
|
10
|
+
}
|
|
5
11
|
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nuxt-customer-portal/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"homepage": "https://nuxt-customer-portal.com",
|
|
6
6
|
"repository": {
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"CHANGELOG.md",
|
|
15
15
|
"app",
|
|
16
16
|
"nuxt.config.ts",
|
|
17
|
+
"i18n",
|
|
17
18
|
"portal.manifest.mjs",
|
|
18
19
|
"README.md"
|
|
19
20
|
],
|
|
@@ -28,14 +29,24 @@
|
|
|
28
29
|
"@nuxt/ui": "^4.10.0",
|
|
29
30
|
"@nuxtjs/i18n": "^10.6.0",
|
|
30
31
|
"@pinia/nuxt": "^1.0.1",
|
|
32
|
+
"@tiptap/core": "^3.29.2",
|
|
33
|
+
"@tiptap/markdown": "^3.29.2",
|
|
34
|
+
"@tiptap/pm": "^3.29.2",
|
|
35
|
+
"@tiptap/starter-kit": "^3.29.2",
|
|
36
|
+
"@tiptap/vue-3": "^3.29.2",
|
|
31
37
|
"pinia": "^4.0.2",
|
|
32
|
-
"
|
|
38
|
+
"remark-gfm": "^4.0.1",
|
|
39
|
+
"remark-parse": "^11.0.0",
|
|
40
|
+
"unified": "^11.0.5",
|
|
41
|
+
"zod": "^4.4.3",
|
|
42
|
+
"@nuxt-customer-portal/core": "^0.5.0"
|
|
33
43
|
},
|
|
34
44
|
"peerDependencies": {
|
|
35
45
|
"nuxt": "^4.5.1",
|
|
36
46
|
"vue": "^3.5.0"
|
|
37
47
|
},
|
|
38
48
|
"devDependencies": {
|
|
49
|
+
"happy-dom": "20.11.2",
|
|
39
50
|
"nuxt": "^4.5.1",
|
|
40
51
|
"vue": "^3.5.0"
|
|
41
52
|
}
|