@kinetis/vector-ui 0.0.0-stage → 0.9.1
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 +34 -0
- package/LICENSE +18 -0
- package/README.md +72 -2
- package/app/app.config.ts +63 -0
- package/app/app.vue +11 -0
- package/app/components/Vx/Action/Dialog.vue +107 -0
- package/app/components/Vx/Cell/Boolean.vue +10 -0
- package/app/components/Vx/Cell/Color.vue +12 -0
- package/app/components/Vx/Cell/Count.vue +12 -0
- package/app/components/Vx/Cell/Date.vue +10 -0
- package/app/components/Vx/Cell/DateTime.vue +10 -0
- package/app/components/Vx/Cell/Email.vue +9 -0
- package/app/components/Vx/Cell/Enum.vue +14 -0
- package/app/components/Vx/Cell/File.vue +30 -0
- package/app/components/Vx/Cell/Image.vue +38 -0
- package/app/components/Vx/Cell/Json.vue +18 -0
- package/app/components/Vx/Cell/KeyValue.vue +25 -0
- package/app/components/Vx/Cell/Markdown.vue +18 -0
- package/app/components/Vx/Cell/Money.vue +16 -0
- package/app/components/Vx/Cell/Number.vue +16 -0
- package/app/components/Vx/Cell/Relation.vue +16 -0
- package/app/components/Vx/Cell/RichText.vue +41 -0
- package/app/components/Vx/Cell/Text.vue +13 -0
- package/app/components/Vx/Cell/Url.vue +11 -0
- package/app/components/Vx/Cell.vue +25 -0
- package/app/components/Vx/CommandPalette.vue +118 -0
- package/app/components/Vx/Confirm.vue +19 -0
- package/app/components/Vx/Dashboard.vue +27 -0
- package/app/components/Vx/FieldsForm.vue +46 -0
- package/app/components/Vx/Import/Dialog.vue +131 -0
- package/app/components/Vx/Inbox.vue +49 -0
- package/app/components/Vx/Input/Boolean.vue +10 -0
- package/app/components/Vx/Input/Currency.vue +25 -0
- package/app/components/Vx/Input/DateTime.vue +38 -0
- package/app/components/Vx/Input/Enum.vue +21 -0
- package/app/components/Vx/Input/KeyValue.vue +78 -0
- package/app/components/Vx/Input/Markdown.vue +120 -0
- package/app/components/Vx/Input/Money.vue +47 -0
- package/app/components/Vx/Input/Number.vue +24 -0
- package/app/components/Vx/Input/Relation.vue +127 -0
- package/app/components/Vx/Input/Repeater.vue +134 -0
- package/app/components/Vx/Input/RichText.vue +182 -0
- package/app/components/Vx/Input/Text.vue +21 -0
- package/app/components/Vx/Input/Textarea.vue +20 -0
- package/app/components/Vx/Input/Upload.vue +89 -0
- package/app/components/Vx/Layout.vue +111 -0
- package/app/components/Vx/List/BulkBar.vue +44 -0
- package/app/components/Vx/List/BulkEdit.vue +91 -0
- package/app/components/Vx/List/ColumnManager.vue +74 -0
- package/app/components/Vx/List/FilterBar.vue +224 -0
- package/app/components/Vx/List/ViewMenu.vue +115 -0
- package/app/components/Vx/Notifications.vue +9 -0
- package/app/components/Vx/Resource/Audit.vue +82 -0
- package/app/components/Vx/Resource/Form.vue +351 -0
- package/app/components/Vx/Resource/RelationPanel.vue +217 -0
- package/app/components/Vx/Resource/Show.vue +35 -0
- package/app/components/Vx/Resource/Table.vue +513 -0
- package/app/components/Vx/Widget/Card.vue +80 -0
- package/app/components/Vx/Widget/Partition.vue +21 -0
- package/app/components/Vx/Widget/Progress.vue +22 -0
- package/app/components/Vx/Widget/Stat.vue +22 -0
- package/app/components/Vx/Widget/Table.vue +31 -0
- package/app/components/Vx/Widget/Trend.vue +37 -0
- package/app/composables/useConfirm.ts +34 -0
- package/app/composables/useLeaveGuard.ts +21 -0
- package/app/composables/useVectorApi.ts +74 -0
- package/app/composables/useVectorAuthStore.ts +252 -0
- package/app/composables/useVectorBranding.ts +45 -0
- package/app/composables/useVectorDashboards.ts +15 -0
- package/app/composables/useVectorExtensions.ts +44 -0
- package/app/composables/useVectorFields.ts +68 -0
- package/app/composables/useVectorForm.ts +36 -0
- package/app/composables/useVectorIdle.ts +39 -0
- package/app/composables/useVectorImpersonation.ts +78 -0
- package/app/composables/useVectorInputs.ts +61 -0
- package/app/composables/useVectorJobs.ts +62 -0
- package/app/composables/useVectorLocale.ts +70 -0
- package/app/composables/useVectorNotifications.ts +24 -0
- package/app/composables/useVectorNotificationsInbox.ts +26 -0
- package/app/composables/useVectorPreferences.ts +30 -0
- package/app/composables/useVectorResource.ts +305 -0
- package/app/composables/useVectorRuntime.ts +42 -0
- package/app/composables/useVectorSchema.ts +56 -0
- package/app/composables/useVectorT.ts +9 -0
- package/app/composables/useVectorViews.ts +25 -0
- package/app/i18n/ar/actions.ts +11 -0
- package/app/i18n/ar/common.ts +23 -0
- package/app/i18n/ar/dashboard.ts +28 -0
- package/app/i18n/ar/fields.ts +77 -0
- package/app/i18n/ar/index.ts +12 -0
- package/app/i18n/ar/jobs.ts +36 -0
- package/app/i18n/ar/list.ts +160 -0
- package/app/i18n/ar/record.ts +62 -0
- package/app/i18n/ar/settings.ts +10 -0
- package/app/i18n/ar/shell.ts +54 -0
- package/app/i18n/de/actions.ts +11 -0
- package/app/i18n/de/common.ts +23 -0
- package/app/i18n/de/dashboard.ts +28 -0
- package/app/i18n/de/fields.ts +77 -0
- package/app/i18n/de/index.ts +12 -0
- package/app/i18n/de/jobs.ts +36 -0
- package/app/i18n/de/list.ts +160 -0
- package/app/i18n/de/record.ts +62 -0
- package/app/i18n/de/settings.ts +10 -0
- package/app/i18n/de/shell.ts +54 -0
- package/app/i18n/en/actions.ts +11 -0
- package/app/i18n/en/common.ts +23 -0
- package/app/i18n/en/dashboard.ts +28 -0
- package/app/i18n/en/fields.ts +77 -0
- package/app/i18n/en/index.ts +12 -0
- package/app/i18n/en/jobs.ts +36 -0
- package/app/i18n/en/list.ts +160 -0
- package/app/i18n/en/record.ts +62 -0
- package/app/i18n/en/settings.ts +10 -0
- package/app/i18n/en/shell.ts +54 -0
- package/app/i18n/es/actions.ts +11 -0
- package/app/i18n/es/common.ts +23 -0
- package/app/i18n/es/dashboard.ts +28 -0
- package/app/i18n/es/fields.ts +77 -0
- package/app/i18n/es/index.ts +12 -0
- package/app/i18n/es/jobs.ts +36 -0
- package/app/i18n/es/list.ts +160 -0
- package/app/i18n/es/record.ts +62 -0
- package/app/i18n/es/settings.ts +10 -0
- package/app/i18n/es/shell.ts +54 -0
- package/app/i18n/fr/actions.ts +11 -0
- package/app/i18n/fr/common.ts +23 -0
- package/app/i18n/fr/dashboard.ts +28 -0
- package/app/i18n/fr/fields.ts +77 -0
- package/app/i18n/fr/index.ts +12 -0
- package/app/i18n/fr/jobs.ts +36 -0
- package/app/i18n/fr/list.ts +160 -0
- package/app/i18n/fr/record.ts +62 -0
- package/app/i18n/fr/settings.ts +10 -0
- package/app/i18n/fr/shell.ts +54 -0
- package/app/i18n/he/actions.ts +11 -0
- package/app/i18n/he/common.ts +23 -0
- package/app/i18n/he/dashboard.ts +28 -0
- package/app/i18n/he/fields.ts +77 -0
- package/app/i18n/he/index.ts +12 -0
- package/app/i18n/he/jobs.ts +36 -0
- package/app/i18n/he/list.ts +160 -0
- package/app/i18n/he/record.ts +62 -0
- package/app/i18n/he/settings.ts +10 -0
- package/app/i18n/he/shell.ts +54 -0
- package/app/i18n/index.ts +111 -0
- package/app/i18n/it/actions.ts +11 -0
- package/app/i18n/it/common.ts +23 -0
- package/app/i18n/it/dashboard.ts +28 -0
- package/app/i18n/it/fields.ts +77 -0
- package/app/i18n/it/index.ts +12 -0
- package/app/i18n/it/jobs.ts +36 -0
- package/app/i18n/it/list.ts +160 -0
- package/app/i18n/it/record.ts +62 -0
- package/app/i18n/it/settings.ts +10 -0
- package/app/i18n/it/shell.ts +54 -0
- package/app/i18n/ja/actions.ts +11 -0
- package/app/i18n/ja/common.ts +23 -0
- package/app/i18n/ja/dashboard.ts +28 -0
- package/app/i18n/ja/fields.ts +77 -0
- package/app/i18n/ja/index.ts +12 -0
- package/app/i18n/ja/jobs.ts +36 -0
- package/app/i18n/ja/list.ts +160 -0
- package/app/i18n/ja/record.ts +62 -0
- package/app/i18n/ja/settings.ts +10 -0
- package/app/i18n/ja/shell.ts +54 -0
- package/app/i18n/ko/actions.ts +11 -0
- package/app/i18n/ko/common.ts +23 -0
- package/app/i18n/ko/dashboard.ts +28 -0
- package/app/i18n/ko/fields.ts +77 -0
- package/app/i18n/ko/index.ts +12 -0
- package/app/i18n/ko/jobs.ts +36 -0
- package/app/i18n/ko/list.ts +160 -0
- package/app/i18n/ko/record.ts +62 -0
- package/app/i18n/ko/settings.ts +10 -0
- package/app/i18n/ko/shell.ts +54 -0
- package/app/i18n/nl/actions.ts +11 -0
- package/app/i18n/nl/common.ts +23 -0
- package/app/i18n/nl/dashboard.ts +28 -0
- package/app/i18n/nl/fields.ts +77 -0
- package/app/i18n/nl/index.ts +12 -0
- package/app/i18n/nl/jobs.ts +36 -0
- package/app/i18n/nl/list.ts +160 -0
- package/app/i18n/nl/record.ts +62 -0
- package/app/i18n/nl/settings.ts +10 -0
- package/app/i18n/nl/shell.ts +54 -0
- package/app/i18n/pl/actions.ts +11 -0
- package/app/i18n/pl/common.ts +23 -0
- package/app/i18n/pl/dashboard.ts +28 -0
- package/app/i18n/pl/fields.ts +77 -0
- package/app/i18n/pl/index.ts +12 -0
- package/app/i18n/pl/jobs.ts +36 -0
- package/app/i18n/pl/list.ts +160 -0
- package/app/i18n/pl/record.ts +62 -0
- package/app/i18n/pl/settings.ts +10 -0
- package/app/i18n/pl/shell.ts +54 -0
- package/app/i18n/pt/actions.ts +11 -0
- package/app/i18n/pt/common.ts +23 -0
- package/app/i18n/pt/dashboard.ts +28 -0
- package/app/i18n/pt/fields.ts +77 -0
- package/app/i18n/pt/index.ts +12 -0
- package/app/i18n/pt/jobs.ts +36 -0
- package/app/i18n/pt/list.ts +160 -0
- package/app/i18n/pt/record.ts +62 -0
- package/app/i18n/pt/settings.ts +10 -0
- package/app/i18n/pt/shell.ts +54 -0
- package/app/i18n/pt-BR/actions.ts +11 -0
- package/app/i18n/pt-BR/common.ts +23 -0
- package/app/i18n/pt-BR/dashboard.ts +28 -0
- package/app/i18n/pt-BR/fields.ts +77 -0
- package/app/i18n/pt-BR/index.ts +12 -0
- package/app/i18n/pt-BR/jobs.ts +36 -0
- package/app/i18n/pt-BR/list.ts +160 -0
- package/app/i18n/pt-BR/record.ts +62 -0
- package/app/i18n/pt-BR/settings.ts +10 -0
- package/app/i18n/pt-BR/shell.ts +54 -0
- package/app/i18n/ru/actions.ts +11 -0
- package/app/i18n/ru/common.ts +23 -0
- package/app/i18n/ru/dashboard.ts +28 -0
- package/app/i18n/ru/fields.ts +77 -0
- package/app/i18n/ru/index.ts +12 -0
- package/app/i18n/ru/jobs.ts +36 -0
- package/app/i18n/ru/list.ts +160 -0
- package/app/i18n/ru/record.ts +62 -0
- package/app/i18n/ru/settings.ts +10 -0
- package/app/i18n/ru/shell.ts +54 -0
- package/app/i18n/tr/actions.ts +11 -0
- package/app/i18n/tr/common.ts +23 -0
- package/app/i18n/tr/dashboard.ts +28 -0
- package/app/i18n/tr/fields.ts +77 -0
- package/app/i18n/tr/index.ts +12 -0
- package/app/i18n/tr/jobs.ts +36 -0
- package/app/i18n/tr/list.ts +160 -0
- package/app/i18n/tr/record.ts +62 -0
- package/app/i18n/tr/settings.ts +10 -0
- package/app/i18n/tr/shell.ts +54 -0
- package/app/i18n/uk/actions.ts +11 -0
- package/app/i18n/uk/common.ts +23 -0
- package/app/i18n/uk/dashboard.ts +28 -0
- package/app/i18n/uk/fields.ts +77 -0
- package/app/i18n/uk/index.ts +12 -0
- package/app/i18n/uk/jobs.ts +36 -0
- package/app/i18n/uk/list.ts +160 -0
- package/app/i18n/uk/record.ts +62 -0
- package/app/i18n/uk/settings.ts +10 -0
- package/app/i18n/uk/shell.ts +54 -0
- package/app/i18n/zh-Hans/actions.ts +11 -0
- package/app/i18n/zh-Hans/common.ts +23 -0
- package/app/i18n/zh-Hans/dashboard.ts +28 -0
- package/app/i18n/zh-Hans/fields.ts +77 -0
- package/app/i18n/zh-Hans/index.ts +12 -0
- package/app/i18n/zh-Hans/jobs.ts +36 -0
- package/app/i18n/zh-Hans/list.ts +160 -0
- package/app/i18n/zh-Hans/record.ts +62 -0
- package/app/i18n/zh-Hans/settings.ts +10 -0
- package/app/i18n/zh-Hans/shell.ts +54 -0
- package/app/layouts/default.vue +8 -0
- package/app/layouts/vector.vue +238 -0
- package/app/middleware/vector-auth.ts +16 -0
- package/app/pages/d/[name].vue +12 -0
- package/app/pages/index.vue +22 -0
- package/app/pages/jobs/[id].vue +52 -0
- package/app/pages/jobs/index.vue +42 -0
- package/app/pages/login.vue +109 -0
- package/app/pages/p/[name].vue +37 -0
- package/app/pages/r/[resource]/[id]/edit.vue +54 -0
- package/app/pages/r/[resource]/[id]/index.vue +143 -0
- package/app/pages/r/[resource]/create.vue +53 -0
- package/app/pages/r/[resource]/index.vue +29 -0
- package/app/pages/settings.vue +104 -0
- package/app/plugins/vector-csp.client.ts +14 -0
- package/app/plugins/vector-extensions.client.ts +84 -0
- package/app/plugins/vector-i18n.ts +76 -0
- package/app/types/vector.ts +438 -0
- package/app/utils/files.ts +14 -0
- package/app/utils/format.ts +100 -0
- package/app/utils/richtext.ts +305 -0
- package/app/utils/rules.ts +113 -0
- package/app/utils/translate.ts +45 -0
- package/app/utils/widgets.ts +11 -0
- package/nuxt.config.ts +83 -0
- package/package.json +83 -5
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { EditorContent, useEditor } from '@tiptap/vue-3'
|
|
3
|
+
import type { Editor } from '@tiptap/core'
|
|
4
|
+
import StarterKit from '@tiptap/starter-kit'
|
|
5
|
+
import { Markdown } from '@tiptap/markdown'
|
|
6
|
+
import type { VectorField } from '#vector/app/types/vector'
|
|
7
|
+
|
|
8
|
+
// A Markdown field, for technical content: edited WYSIWYG, stored as the
|
|
9
|
+
// Markdown source (the editor reads and writes it through @tiptap/markdown).
|
|
10
|
+
// "Source" shows the text itself, to edit what the toolbar does not offer.
|
|
11
|
+
const props = defineProps<{ field: VectorField, rules: ((v: unknown) => true | string)[], errorMessages: string[], record?: Record<string, unknown>, resource?: string }>()
|
|
12
|
+
const model = defineModel<string | null>()
|
|
13
|
+
|
|
14
|
+
const { t } = useVectorT()
|
|
15
|
+
const toolbar = computed(() => props.field.markdown?.toolbar ?? ['bold', 'italic', 'strike', 'code', 'heading', 'list', 'blockquote', 'codeBlock', 'horizontalRule', 'link'])
|
|
16
|
+
const on = (button: string) => toolbar.value.includes(button)
|
|
17
|
+
const source = ref(false)
|
|
18
|
+
let emitting = false
|
|
19
|
+
|
|
20
|
+
const editor = useEditor({
|
|
21
|
+
extensions: [
|
|
22
|
+
StarterKit.configure({
|
|
23
|
+
link: { openOnClick: false, autolink: true, protocols: [], isAllowedUri: (url: string) => allowedHref(url) },
|
|
24
|
+
heading: { levels: [1, 2, 3, 4] },
|
|
25
|
+
}),
|
|
26
|
+
Markdown,
|
|
27
|
+
],
|
|
28
|
+
injectNonce: import.meta.client ? (window.__VECTOR__?.cspNonce ?? undefined) : undefined,
|
|
29
|
+
editorProps: { attributes: { 'class': 'vx-richtext-content', 'aria-label': props.field.label, 'role': 'textbox', 'aria-multiline': 'true' } },
|
|
30
|
+
onCreate: ({ editor: current }) => {
|
|
31
|
+
current.commands.setContent(model.value ?? '', { contentType: 'markdown', emitUpdate: false })
|
|
32
|
+
},
|
|
33
|
+
onUpdate: ({ editor: current }) => {
|
|
34
|
+
const text = current.getMarkdown().trim()
|
|
35
|
+
emitting = true
|
|
36
|
+
model.value = text === '' ? null : text
|
|
37
|
+
},
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
watch(model, (value) => {
|
|
41
|
+
if (emitting) {
|
|
42
|
+
emitting = false
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
if (!source.value) editor.value?.commands.setContent(value ?? '', { contentType: 'markdown', emitUpdate: false })
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
// leaving the source view reads the text back into the editor
|
|
49
|
+
watch(source, (showing) => {
|
|
50
|
+
if (!showing) editor.value?.commands.setContent(model.value ?? '', { contentType: 'markdown', emitUpdate: false })
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
onBeforeUnmount(() => editor.value?.destroy())
|
|
54
|
+
|
|
55
|
+
const active = (name: string, attrs?: Record<string, unknown>) => editor.value?.isActive(name, attrs) ?? false
|
|
56
|
+
const run = (command: (e: Editor) => void) => {
|
|
57
|
+
if (editor.value) command(editor.value)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const linkMenu = ref(false)
|
|
61
|
+
const linkHref = ref('')
|
|
62
|
+
const linkError = computed(() => linkHref.value.trim() !== '' && !allowedHref(linkHref.value.trim()) ? t('fields.richtext.linkRefused') : null)
|
|
63
|
+
function openLink() {
|
|
64
|
+
linkHref.value = String(editor.value?.getAttributes('link').href ?? '')
|
|
65
|
+
linkMenu.value = true
|
|
66
|
+
}
|
|
67
|
+
function applyLink() {
|
|
68
|
+
const href = linkHref.value.trim()
|
|
69
|
+
if (linkError.value) return
|
|
70
|
+
run(e => href === '' ? e.chain().focus().unsetLink().run() : e.chain().focus().extendMarkRange('link').setLink({ href }).run())
|
|
71
|
+
linkMenu.value = false
|
|
72
|
+
}
|
|
73
|
+
</script>
|
|
74
|
+
|
|
75
|
+
<template>
|
|
76
|
+
<v-input :model-value="model" :rules="rules" :error-messages="errorMessages" :hint="field.help ?? undefined" :persistent-hint="!!field.help" class="vx-richtext">
|
|
77
|
+
<div class="vx-richtext-frame" :class="{ 'vx-richtext-frame--error': errorMessages.length > 0 }">
|
|
78
|
+
<div class="text-caption text-medium-emphasis px-3 pt-2">{{ field.label }}</div>
|
|
79
|
+
<div class="vx-richtext-toolbar d-flex flex-wrap align-center ga-1 px-2 py-1" role="toolbar" :aria-label="t('fields.richtext.toolbar', { label: field.label })">
|
|
80
|
+
<template v-if="!source">
|
|
81
|
+
<v-btn v-if="on('bold')" icon="mdi-format-bold" size="small" variant="text" :active="active('bold')" :aria-label="t('fields.richtext.bold')" @click="run(e => e.chain().focus().toggleBold().run())" />
|
|
82
|
+
<v-btn v-if="on('italic')" icon="mdi-format-italic" size="small" variant="text" :active="active('italic')" :aria-label="t('fields.richtext.italic')" @click="run(e => e.chain().focus().toggleItalic().run())" />
|
|
83
|
+
<v-btn v-if="on('strike')" icon="mdi-format-strikethrough" size="small" variant="text" :active="active('strike')" :aria-label="t('fields.richtext.strike')" @click="run(e => e.chain().focus().toggleStrike().run())" />
|
|
84
|
+
<v-btn v-if="on('code')" icon="mdi-code-tags" size="small" variant="text" :active="active('code')" :aria-label="t('fields.richtext.code')" @click="run(e => e.chain().focus().toggleCode().run())" />
|
|
85
|
+
<template v-if="on('heading')">
|
|
86
|
+
<v-divider vertical class="mx-1" />
|
|
87
|
+
<v-btn v-for="level in ([1, 2, 3] as const)" :key="level" :icon="`mdi-format-header-${level}`" size="small" variant="text" :active="active('heading', { level })" :aria-label="t('fields.richtext.heading', { level })" @click="run(e => e.chain().focus().toggleHeading({ level }).run())" />
|
|
88
|
+
</template>
|
|
89
|
+
<template v-if="on('list')">
|
|
90
|
+
<v-divider vertical class="mx-1" />
|
|
91
|
+
<v-btn icon="mdi-format-list-bulleted" size="small" variant="text" :active="active('bulletList')" :aria-label="t('fields.richtext.bulletList')" @click="run(e => e.chain().focus().toggleBulletList().run())" />
|
|
92
|
+
<v-btn icon="mdi-format-list-numbered" size="small" variant="text" :active="active('orderedList')" :aria-label="t('fields.richtext.orderedList')" @click="run(e => e.chain().focus().toggleOrderedList().run())" />
|
|
93
|
+
</template>
|
|
94
|
+
<v-btn v-if="on('blockquote')" icon="mdi-format-quote-close" size="small" variant="text" :active="active('blockquote')" :aria-label="t('fields.richtext.blockquote')" @click="run(e => e.chain().focus().toggleBlockquote().run())" />
|
|
95
|
+
<v-btn v-if="on('codeBlock')" icon="mdi-code-braces" size="small" variant="text" :active="active('codeBlock')" :aria-label="t('fields.richtext.codeBlock')" @click="run(e => e.chain().focus().toggleCodeBlock().run())" />
|
|
96
|
+
<v-btn v-if="on('horizontalRule')" icon="mdi-minus" size="small" variant="text" :aria-label="t('fields.richtext.horizontalRule')" @click="run(e => e.chain().focus().setHorizontalRule().run())" />
|
|
97
|
+
<v-menu v-if="on('link')" v-model="linkMenu" :close-on-content-click="false" location="bottom">
|
|
98
|
+
<template #activator="{ props: activator }">
|
|
99
|
+
<v-btn v-bind="activator" icon="mdi-link-variant" size="small" variant="text" :active="active('link')" :aria-label="t('fields.richtext.link')" @click="openLink" />
|
|
100
|
+
</template>
|
|
101
|
+
<v-card min-width="320" class="pa-3">
|
|
102
|
+
<v-text-field v-model="linkHref" :label="t('fields.richtext.linkUrl')" :error-messages="linkError ? [linkError] : []" autofocus @keydown.enter.prevent="applyLink" />
|
|
103
|
+
<div class="d-flex justify-end ga-2">
|
|
104
|
+
<v-btn variant="text" @click="linkHref = ''; applyLink()">{{ t('common.remove') }}</v-btn>
|
|
105
|
+
<v-btn color="primary" :disabled="!!linkError" @click="applyLink">{{ t('common.apply') }}</v-btn>
|
|
106
|
+
</div>
|
|
107
|
+
</v-card>
|
|
108
|
+
</v-menu>
|
|
109
|
+
</template>
|
|
110
|
+
<v-spacer />
|
|
111
|
+
<v-btn size="small" variant="text" :prepend-icon="source ? 'mdi-eye-outline' : 'mdi-language-markdown-outline'" :aria-pressed="source" @click="source = !source">
|
|
112
|
+
{{ source ? t('fields.richtext.visual') : t('fields.richtext.source') }}
|
|
113
|
+
</v-btn>
|
|
114
|
+
</div>
|
|
115
|
+
<v-divider />
|
|
116
|
+
<v-textarea v-if="source" v-model="model" :aria-label="t('fields.richtext.sourceOf', { label: field.label })" variant="plain" auto-grow rows="6" hide-details class="px-3 font-monospace" />
|
|
117
|
+
<EditorContent v-show="!source" :editor="editor" class="vx-richtext-editor" />
|
|
118
|
+
</div>
|
|
119
|
+
</v-input>
|
|
120
|
+
</template>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { VectorField } from '#vector/app/types/vector'
|
|
3
|
+
|
|
4
|
+
// Edited as a decimal amount, held as minor units — the wire value, in as
|
|
5
|
+
// many decimals as the currency has (none for yen, three for dinars). The
|
|
6
|
+
// text is what was typed until the field is left; only then, when the
|
|
7
|
+
// value changes from outside, or when the currency changes, is it written
|
|
8
|
+
// out in full.
|
|
9
|
+
const props = defineProps<{ field: VectorField, rules: ((v: unknown) => true | string)[], errorMessages: string[], record: Record<string, unknown> }>()
|
|
10
|
+
const model = defineModel<number | null>()
|
|
11
|
+
|
|
12
|
+
const currency = computed(() => currencyOf(props.field, props.record))
|
|
13
|
+
|
|
14
|
+
const digits = computed(() => minorDigits(currency.value))
|
|
15
|
+
const format = (value: number | null | undefined) => (typeof value === 'number' ? (value / 10 ** digits.value).toFixed(digits.value) : '')
|
|
16
|
+
const text = ref(format(model.value))
|
|
17
|
+
let typed: number | null | undefined = model.value
|
|
18
|
+
|
|
19
|
+
watch(model, (value) => {
|
|
20
|
+
if (value !== typed) text.value = format(value)
|
|
21
|
+
typed = value
|
|
22
|
+
})
|
|
23
|
+
// the same minor units read in another currency's decimals
|
|
24
|
+
watch(digits, () => { text.value = format(model.value) })
|
|
25
|
+
|
|
26
|
+
function input(value: string) {
|
|
27
|
+
text.value = value
|
|
28
|
+
const parsed = Number.parseFloat(String(value ?? '').replace(',', '.'))
|
|
29
|
+
typed = Number.isFinite(parsed) ? Math.round(parsed * 10 ** digits.value) : null
|
|
30
|
+
model.value = typed
|
|
31
|
+
}
|
|
32
|
+
</script>
|
|
33
|
+
|
|
34
|
+
<template>
|
|
35
|
+
<v-text-field
|
|
36
|
+
:model-value="text"
|
|
37
|
+
:label="field.label"
|
|
38
|
+
:rules="rules"
|
|
39
|
+
:error-messages="errorMessages"
|
|
40
|
+
:suffix="currency ?? undefined"
|
|
41
|
+
:inputmode="digits > 0 ? 'decimal' : 'numeric'"
|
|
42
|
+
:hint="field.help ?? undefined"
|
|
43
|
+
:persistent-hint="!!field.help"
|
|
44
|
+
@update:model-value="input"
|
|
45
|
+
@blur="text = format(model)"
|
|
46
|
+
/>
|
|
47
|
+
</template>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { fieldProps, type VectorField } from '#vector/app/types/vector'
|
|
3
|
+
|
|
4
|
+
const props = defineProps<{ field: VectorField, rules: ((v: unknown) => true | string)[], errorMessages: string[], record?: Record<string, unknown>, resource?: string }>()
|
|
5
|
+
const model = defineModel<number | null>()
|
|
6
|
+
const decimals = computed(() => {
|
|
7
|
+
const d = fieldProps(props.field).decimals
|
|
8
|
+
return typeof d === 'number' ? d : (props.field.valueType === 'float' ? 2 : 0)
|
|
9
|
+
})
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
<template>
|
|
13
|
+
<v-number-input
|
|
14
|
+
v-model="model"
|
|
15
|
+
:label="field.label"
|
|
16
|
+
:rules="rules"
|
|
17
|
+
:error-messages="errorMessages"
|
|
18
|
+
:precision="decimals"
|
|
19
|
+
:step="decimals > 0 ? 1 / 10 ** decimals : 1"
|
|
20
|
+
:hint="field.help ?? undefined"
|
|
21
|
+
:persistent-hint="!!field.help"
|
|
22
|
+
control-variant="stacked"
|
|
23
|
+
/>
|
|
24
|
+
</template>
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { VectorField, VectorRelationValue } from '#vector/app/types/vector'
|
|
3
|
+
|
|
4
|
+
// belongsTo edits one identifier, manyToMany a list; both search the
|
|
5
|
+
// target's title on the server, debounced, dropping stale answers. A
|
|
6
|
+
// dependent choice list searches with the form's value of the field it
|
|
7
|
+
// depends on, waits for one, and drops its choice when that value changes.
|
|
8
|
+
// An option shows the target's subtitle and thumbnail when it has them;
|
|
9
|
+
// thumbnails come through the API client as data URLs, like image cells.
|
|
10
|
+
const props = defineProps<{ field: VectorField, rules: ((v: unknown) => true | string)[], errorMessages: string[], resource: string, record?: Record<string, unknown>, disabled?: boolean }>()
|
|
11
|
+
const model = defineModel<number | string | (number | string)[] | null>()
|
|
12
|
+
const multiple = computed(() => props.field.type === 'manyToMany')
|
|
13
|
+
const client = useVectorResource(() => props.resource)
|
|
14
|
+
const { t, noun } = useVectorT()
|
|
15
|
+
|
|
16
|
+
const items = ref<VectorRelationValue[]>([])
|
|
17
|
+
const target = useVectorResource(() => props.field.relation?.resource ?? props.resource)
|
|
18
|
+
const thumbs = reactive<Record<string, string>>({})
|
|
19
|
+
const requested = new Set<string>()
|
|
20
|
+
|
|
21
|
+
function loadThumbs(options: VectorRelationValue[]) {
|
|
22
|
+
for (const option of options) {
|
|
23
|
+
const key = String(option.id)
|
|
24
|
+
if (!option.image || requested.has(key)) continue
|
|
25
|
+
requested.add(key)
|
|
26
|
+
target.fileBlob(option.id, option.image).then(dataUrl).then((url) => { thumbs[key] = url }).catch(() => {})
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const search = ref('')
|
|
31
|
+
const loading = ref(false)
|
|
32
|
+
let requestId = 0
|
|
33
|
+
|
|
34
|
+
const dependsOn = computed(() => props.field.relation?.dependsOn ?? null)
|
|
35
|
+
const dependency = computed<string | number | null>(() => {
|
|
36
|
+
if (!dependsOn.value) return null
|
|
37
|
+
const value = props.record?.[dependsOn.value.field]
|
|
38
|
+
const key = value && typeof value === 'object' && 'id' in value ? (value as { id: unknown }).id : value
|
|
39
|
+
return typeof key === 'number' || (typeof key === 'string' && key !== '') ? key : typeof key === 'boolean' ? Number(key) : null
|
|
40
|
+
})
|
|
41
|
+
const waiting = computed(() => dependsOn.value !== null && dependency.value === null)
|
|
42
|
+
|
|
43
|
+
async function fetchOptions(query: string, ids: (number | string)[] = []) {
|
|
44
|
+
if (waiting.value && !ids.length) {
|
|
45
|
+
items.value = items.value.filter(i => isSelected(i.id))
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
const id = ++requestId
|
|
49
|
+
loading.value = true
|
|
50
|
+
try {
|
|
51
|
+
const depends = dependsOn.value && dependency.value !== null ? { [dependsOn.value.field]: dependency.value } : {}
|
|
52
|
+
const result = await client.options(props.field.name, query, ids, depends)
|
|
53
|
+
if (id === requestId) {
|
|
54
|
+
// keep the selected entries visible while the list changes
|
|
55
|
+
const selected = items.value.filter(i => isSelected(i.id))
|
|
56
|
+
const merged = [...selected, ...result.data.filter(r => !selected.some(s => s.id === r.id))]
|
|
57
|
+
items.value = merged
|
|
58
|
+
loadThumbs(result.data)
|
|
59
|
+
}
|
|
60
|
+
} finally {
|
|
61
|
+
if (id === requestId) loading.value = false
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isSelected(id: number | string) {
|
|
66
|
+
return multiple.value ? Array.isArray(model.value) && model.value.includes(id) : model.value === id
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const debounced = useDebounceFn((q: string) => fetchOptions(q), 250)
|
|
70
|
+
watch(search, (q) => { if (typeof q === 'string') debounced(q) })
|
|
71
|
+
|
|
72
|
+
// Another team, other agents: a choice made for the old value no longer holds.
|
|
73
|
+
watch(dependency, (now, before) => {
|
|
74
|
+
if (now === before) return
|
|
75
|
+
if (!multiple.value && model.value !== null && model.value !== undefined) model.value = null
|
|
76
|
+
items.value = []
|
|
77
|
+
fetchOptions('')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
// Resolve the labels of an initial value once.
|
|
81
|
+
onMounted(async () => {
|
|
82
|
+
const initial: (number | string)[] = multiple.value
|
|
83
|
+
? (Array.isArray(model.value) ? model.value : [])
|
|
84
|
+
: (model.value === null || model.value === undefined || Array.isArray(model.value) ? [] : [model.value])
|
|
85
|
+
await fetchOptions('', initial.length ? initial : [])
|
|
86
|
+
if (initial.length) await fetchOptions('')
|
|
87
|
+
})
|
|
88
|
+
</script>
|
|
89
|
+
|
|
90
|
+
<template>
|
|
91
|
+
<v-autocomplete
|
|
92
|
+
v-model="model"
|
|
93
|
+
v-model:search="search"
|
|
94
|
+
:label="field.label"
|
|
95
|
+
:items="items"
|
|
96
|
+
item-title="label"
|
|
97
|
+
item-value="id"
|
|
98
|
+
:multiple="multiple"
|
|
99
|
+
:chips="multiple"
|
|
100
|
+
:closable-chips="multiple"
|
|
101
|
+
:loading="loading"
|
|
102
|
+
:rules="rules"
|
|
103
|
+
:error-messages="errorMessages"
|
|
104
|
+
:clearable="field.nullable === true || multiple"
|
|
105
|
+
:disabled="disabled || waiting"
|
|
106
|
+
no-filter
|
|
107
|
+
:hint="waiting ? t('fields.relation.dependsFirst', { label: noun(dependsOn!.label) }) : (field.help ?? undefined)"
|
|
108
|
+
:persistent-hint="waiting || !!field.help"
|
|
109
|
+
:placeholder="t('fields.relation.placeholder')"
|
|
110
|
+
>
|
|
111
|
+
<template #item="{ props: itemProps, item }">
|
|
112
|
+
<v-list-item v-bind="itemProps" :subtitle="(item as VectorRelationValue).subtitle ?? undefined">
|
|
113
|
+
<template v-if="thumbs[String((item as VectorRelationValue).id)]" #prepend>
|
|
114
|
+
<v-avatar rounded size="32"><v-img :src="thumbs[String((item as VectorRelationValue).id)]" alt="" cover /></v-avatar>
|
|
115
|
+
</template>
|
|
116
|
+
</v-list-item>
|
|
117
|
+
</template>
|
|
118
|
+
<template v-if="multiple" #chip="{ props: chipProps, item }">
|
|
119
|
+
<v-chip v-bind="chipProps" :prepend-avatar="thumbs[String((item as VectorRelationValue).id)]" :title="(item as VectorRelationValue).subtitle ?? undefined" />
|
|
120
|
+
</template>
|
|
121
|
+
<template v-else #selection="{ item }">
|
|
122
|
+
<v-avatar v-if="thumbs[String((item as VectorRelationValue).id)]" rounded size="24" class="me-2"><v-img :src="thumbs[String((item as VectorRelationValue).id)]" alt="" cover /></v-avatar>
|
|
123
|
+
<span>{{ (item as VectorRelationValue).label }}</span>
|
|
124
|
+
<span v-if="(item as VectorRelationValue).subtitle" class="text-medium-emphasis ms-2 vx-relation-subtitle">{{ (item as VectorRelationValue).subtitle }}</span>
|
|
125
|
+
</template>
|
|
126
|
+
</v-autocomplete>
|
|
127
|
+
</template>
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { VectorField } from '#vector/app/types/vector'
|
|
3
|
+
|
|
4
|
+
// An owned relation's rows edited inside the parent's form: one line of
|
|
5
|
+
// the child's inputs per row, added and removed within the repeater's
|
|
6
|
+
// bounds. The form sends the whole list; a row keeps its `id`, so the
|
|
7
|
+
// server updates that child, and a row without one is created.
|
|
8
|
+
const props = defineProps<{
|
|
9
|
+
field: VectorField
|
|
10
|
+
/** the form's rules for the field itself; a row's inputs carry their own */
|
|
11
|
+
rules?: unknown[]
|
|
12
|
+
errorMessages: string[]
|
|
13
|
+
resource: string
|
|
14
|
+
record?: Record<string, unknown>
|
|
15
|
+
/** the form's server errors, keyed `field.row.column` for a row's own */
|
|
16
|
+
serverErrors?: Record<string, string[]>
|
|
17
|
+
disabled?: boolean
|
|
18
|
+
}>()
|
|
19
|
+
const model = defineModel<Record<string, unknown>[] | null>()
|
|
20
|
+
|
|
21
|
+
const { t, noun } = useVectorT()
|
|
22
|
+
const { resource: resourceOf } = useVectorSchema()
|
|
23
|
+
const { inputFor } = useVectorInputs()
|
|
24
|
+
|
|
25
|
+
const child = computed(() => props.field.relation ? resourceOf(props.field.relation.resource) : undefined)
|
|
26
|
+
const columns = computed<VectorField[]>(() => (props.field.repeater?.fields ?? [])
|
|
27
|
+
.map(name => child.value?.fields.find(f => f.name === name))
|
|
28
|
+
.filter((f): f is VectorField => f !== undefined))
|
|
29
|
+
const min = computed(() => props.field.repeater?.min ?? 0)
|
|
30
|
+
const max = computed(() => props.field.repeater?.max ?? 100)
|
|
31
|
+
const rows = computed(() => (Array.isArray(model.value) ? model.value : []))
|
|
32
|
+
const width = computed(() => Math.max(3, Math.floor(12 / Math.max(1, Math.min(columns.value.length, 4)))))
|
|
33
|
+
|
|
34
|
+
// Stable keys, so removing a row keeps every other row's inputs (and a
|
|
35
|
+
// relation input's loaded label) with the row it belongs to.
|
|
36
|
+
let next = 0
|
|
37
|
+
const keys = ref<number[]>([])
|
|
38
|
+
watch(() => rows.value.length, (length) => {
|
|
39
|
+
if (keys.value.length !== length) keys.value = Array.from({ length }, () => next++)
|
|
40
|
+
}, { immediate: true })
|
|
41
|
+
|
|
42
|
+
function blank(): Record<string, unknown> {
|
|
43
|
+
return Object.fromEntries(columns.value.map(f => [f.name, f.hasDefault ? f.default : f.type === 'boolean' ? false : null]))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function add() {
|
|
47
|
+
keys.value = [...keys.value, next++]
|
|
48
|
+
model.value = [...rows.value, blank()]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function remove(index: number) {
|
|
52
|
+
keys.value = keys.value.filter((_, i) => i !== index)
|
|
53
|
+
model.value = rows.value.filter((_, i) => i !== index)
|
|
54
|
+
clearRowErrors()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function set(index: number, name: string, value: unknown) {
|
|
58
|
+
model.value = rows.value.map((row, i) => (i === index ? { ...row, [name]: value } : row))
|
|
59
|
+
// The form owns the server errors and lets each input clear its own.
|
|
60
|
+
// eslint-disable-next-line vue/no-mutating-props
|
|
61
|
+
if (props.serverErrors) delete props.serverErrors[`${props.field.name}.${index}.${name}`]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Row errors are keyed by position, which a removal shifts: they go with it. */
|
|
65
|
+
function clearRowErrors() {
|
|
66
|
+
for (const key of Object.keys(props.serverErrors ?? {})) {
|
|
67
|
+
if (key.startsWith(`${props.field.name}.`)) delete props.serverErrors![key]
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// A row's inputs read the record they belong to through the relation back
|
|
72
|
+
// to it: a line priced in `order.currency` sees the order being edited.
|
|
73
|
+
const inverse = computed(() => props.field.relation?.inverse)
|
|
74
|
+
const rowRecord = (row: Record<string, unknown>) => (inverse.value ? { ...row, [inverse.value]: props.record ?? {} } : row)
|
|
75
|
+
|
|
76
|
+
const rowErrors = (index: number, name: string) => props.serverErrors?.[`${props.field.name}.${index}.${name}`] ?? []
|
|
77
|
+
|
|
78
|
+
// A new record starts with the rows it must hold.
|
|
79
|
+
onMounted(() => {
|
|
80
|
+
if (child.value && rows.value.length < min.value && !props.record?.id) {
|
|
81
|
+
model.value = [...rows.value, ...Array.from({ length: min.value - rows.value.length }, blank)]
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
</script>
|
|
85
|
+
|
|
86
|
+
<template>
|
|
87
|
+
<v-card variant="outlined" class="pa-3" role="group" :aria-label="field.label">
|
|
88
|
+
<div class="d-flex align-center mb-2">
|
|
89
|
+
<span class="text-subtitle-2">{{ field.label }}</span>
|
|
90
|
+
<v-spacer />
|
|
91
|
+
<span class="text-caption text-medium-emphasis">{{ t('fields.repeater.count', { count: rows.length, max }) }}</span>
|
|
92
|
+
</div>
|
|
93
|
+
<v-alert v-for="message in errorMessages" :key="message" type="error" variant="tonal" density="compact" class="mb-2" :text="message" />
|
|
94
|
+
<p v-if="!child" class="text-medium-emphasis text-body-2">{{ t('fields.repeater.unavailable') }}</p>
|
|
95
|
+
<template v-else>
|
|
96
|
+
<div
|
|
97
|
+
v-for="(row, index) in rows"
|
|
98
|
+
:key="keys[index]"
|
|
99
|
+
class="d-flex ga-2 align-start mb-1"
|
|
100
|
+
role="group"
|
|
101
|
+
:aria-label="t('fields.repeater.row', { label: child.labelSingular, n: index + 1 })"
|
|
102
|
+
>
|
|
103
|
+
<v-row density="compact" class="flex-grow-1">
|
|
104
|
+
<v-col v-for="column in columns" :key="column.name" cols="12" :md="width">
|
|
105
|
+
<component
|
|
106
|
+
:is="inputFor(column)"
|
|
107
|
+
:model-value="row[column.name]"
|
|
108
|
+
:field="column"
|
|
109
|
+
:rules="rulesFor(column, row.id ? 'update' : 'create')"
|
|
110
|
+
:error-messages="rowErrors(index, column.name)"
|
|
111
|
+
:record="rowRecord(row)"
|
|
112
|
+
:resource="child.slug"
|
|
113
|
+
:disabled="disabled"
|
|
114
|
+
density="compact"
|
|
115
|
+
@update:model-value="(value: unknown) => set(index, column.name, value)"
|
|
116
|
+
/>
|
|
117
|
+
</v-col>
|
|
118
|
+
</v-row>
|
|
119
|
+
<v-btn
|
|
120
|
+
icon="mdi-close"
|
|
121
|
+
variant="text"
|
|
122
|
+
size="small"
|
|
123
|
+
class="mt-2"
|
|
124
|
+
:disabled="disabled || rows.length <= min"
|
|
125
|
+
:aria-label="t('fields.repeater.remove', { label: noun(child.labelSingular), n: index + 1 })"
|
|
126
|
+
@click="remove(index)"
|
|
127
|
+
/>
|
|
128
|
+
</div>
|
|
129
|
+
<v-btn variant="tonal" size="small" prepend-icon="mdi-plus" :disabled="disabled || rows.length >= max" @click="add">
|
|
130
|
+
{{ t('fields.repeater.add', { label: noun(child.labelSingular) }) }}
|
|
131
|
+
</v-btn>
|
|
132
|
+
</template>
|
|
133
|
+
</v-card>
|
|
134
|
+
</template>
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { EditorContent, useEditor } from '@tiptap/vue-3'
|
|
3
|
+
import type { Editor } from '@tiptap/core'
|
|
4
|
+
import type { VectorField } from '#vector/app/types/vector'
|
|
5
|
+
|
|
6
|
+
// A rich-text field: a Tiptap editor over exactly the field's schema. Its
|
|
7
|
+
// value is the document as JSON text — null when empty — which the server
|
|
8
|
+
// validates against the same schema. Pasted content outside the schema is
|
|
9
|
+
// dropped by construction: ProseMirror keeps only what the schema holds.
|
|
10
|
+
// An image is uploaded through the field's image field and stored by its
|
|
11
|
+
// key; a mention picks a record of the field's `mentions` relation.
|
|
12
|
+
const props = defineProps<{ field: VectorField, rules: ((v: unknown) => true | string)[], errorMessages: string[], record?: Record<string, unknown>, resource?: string }>()
|
|
13
|
+
const model = defineModel<string | null>()
|
|
14
|
+
|
|
15
|
+
const { t } = useVectorT()
|
|
16
|
+
const client = useVectorResource(() => props.resource ?? '')
|
|
17
|
+
const { richText: custom } = useVectorExtensions()
|
|
18
|
+
const rich = computed(() => props.field.richtext ?? { features: [], imageField: null, mentions: null, storesHtml: false, maxBytes: 262_144 })
|
|
19
|
+
const on = (feature: string) => rich.value.features.includes(feature)
|
|
20
|
+
|
|
21
|
+
// images uploaded in this form, shown from the picked file until the record is saved
|
|
22
|
+
const uploaded = new Map<string, string>()
|
|
23
|
+
|
|
24
|
+
async function imageSource(key: string): Promise<string | null> {
|
|
25
|
+
if (uploaded.has(key)) return uploaded.get(key)!
|
|
26
|
+
const id = props.record?.id
|
|
27
|
+
if (id === undefined || id === null || !rich.value.imageField) return null
|
|
28
|
+
return await dataUrl(await client.fileBlob(id as string | number, rich.value.imageField, key))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function mentionItems(query: string) {
|
|
32
|
+
if (!rich.value.mentions) return []
|
|
33
|
+
return (await client.options(rich.value.mentions, query)).data.slice(0, 8).map(o => ({ id: o.id, label: o.label }))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const extensions = richTextExtensions({ features: rich.value.features, placeholder: props.field.help ?? undefined, imageSource, mentionItems, custom })
|
|
37
|
+
let emitting = false
|
|
38
|
+
|
|
39
|
+
const editor = useEditor({
|
|
40
|
+
content: model.value ? toDocument(model.value, extensions) : null,
|
|
41
|
+
extensions,
|
|
42
|
+
// Tiptap's own style element carries the hosted shell's nonce, so a strict style-src admits it
|
|
43
|
+
injectNonce: import.meta.client ? (window.__VECTOR__?.cspNonce ?? undefined) : undefined,
|
|
44
|
+
editorProps: { attributes: { 'class': 'vx-richtext-content', 'aria-label': props.field.label, 'role': 'textbox', 'aria-multiline': 'true' } },
|
|
45
|
+
onUpdate: ({ editor: current }) => {
|
|
46
|
+
const doc = current.getJSON()
|
|
47
|
+
emitting = true
|
|
48
|
+
model.value = isEmptyDocument(doc) ? null : JSON.stringify(doc)
|
|
49
|
+
},
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
// a value set from outside (a reset, a repeater row) replaces the content
|
|
53
|
+
watch(model, (value) => {
|
|
54
|
+
if (emitting) {
|
|
55
|
+
emitting = false
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
editor.value?.commands.setContent(value ? toDocument(value, extensions) : null, { emitUpdate: false })
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
onBeforeUnmount(() => editor.value?.destroy())
|
|
62
|
+
|
|
63
|
+
const active = (name: string, attrs?: Record<string, unknown>) => editor.value?.isActive(name, attrs) ?? false
|
|
64
|
+
const run = (command: (e: Editor) => void) => {
|
|
65
|
+
if (editor.value) command(editor.value)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// links: http(s), mailto or relative only, as the server requires
|
|
69
|
+
const linkMenu = ref(false)
|
|
70
|
+
const linkHref = ref('')
|
|
71
|
+
const linkError = computed(() => linkHref.value.trim() !== '' && !allowedHref(linkHref.value.trim()) ? t('fields.richtext.linkRefused') : null)
|
|
72
|
+
function openLink() {
|
|
73
|
+
linkHref.value = String(editor.value?.getAttributes('link').href ?? '')
|
|
74
|
+
linkMenu.value = true
|
|
75
|
+
}
|
|
76
|
+
function applyLink() {
|
|
77
|
+
const href = linkHref.value.trim()
|
|
78
|
+
if (linkError.value) return
|
|
79
|
+
run(e => href === '' ? e.chain().focus().unsetLink().run() : e.chain().focus().extendMarkRange('link').setLink({ href }).run())
|
|
80
|
+
linkMenu.value = false
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// images: uploaded at once through the image field, stored by key
|
|
84
|
+
const picker = ref<HTMLInputElement | null>(null)
|
|
85
|
+
const uploading = ref(false)
|
|
86
|
+
const uploadError = ref<string | null>(null)
|
|
87
|
+
async function insertImage(event: Event) {
|
|
88
|
+
const file = (event.target as HTMLInputElement).files?.[0]
|
|
89
|
+
;(event.target as HTMLInputElement).value = ''
|
|
90
|
+
if (!file || !rich.value.imageField) return
|
|
91
|
+
uploading.value = true
|
|
92
|
+
uploadError.value = null
|
|
93
|
+
try {
|
|
94
|
+
const { key } = await client.upload(rich.value.imageField, file)
|
|
95
|
+
uploaded.set(key, await dataUrl(file))
|
|
96
|
+
run(e => e.chain().focus().setImage({ src: key, alt: file.name }).run())
|
|
97
|
+
} catch (error) {
|
|
98
|
+
uploadError.value = vectorErrorMessage(error)
|
|
99
|
+
} finally {
|
|
100
|
+
uploading.value = false
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const inTable = computed(() => active('table'))
|
|
105
|
+
</script>
|
|
106
|
+
|
|
107
|
+
<template>
|
|
108
|
+
<v-input :model-value="model" :rules="rules" :error-messages="uploadError ? [uploadError, ...errorMessages] : errorMessages" :hint="field.help ?? undefined" :persistent-hint="!!field.help" class="vx-richtext">
|
|
109
|
+
<div class="vx-richtext-frame" :class="{ 'vx-richtext-frame--error': errorMessages.length > 0 }">
|
|
110
|
+
<div class="text-caption text-medium-emphasis px-3 pt-2">{{ field.label }}</div>
|
|
111
|
+
<div class="vx-richtext-toolbar d-flex flex-wrap align-center ga-1 px-2 py-1" role="toolbar" :aria-label="t('fields.richtext.toolbar', { label: field.label })">
|
|
112
|
+
<v-btn v-if="on('bold')" icon="mdi-format-bold" size="small" variant="text" :active="active('bold')" :aria-label="t('fields.richtext.bold')" @click="run(e => e.chain().focus().toggleBold().run())" />
|
|
113
|
+
<v-btn v-if="on('italic')" icon="mdi-format-italic" size="small" variant="text" :active="active('italic')" :aria-label="t('fields.richtext.italic')" @click="run(e => e.chain().focus().toggleItalic().run())" />
|
|
114
|
+
<v-btn v-if="on('underline')" icon="mdi-format-underline" size="small" variant="text" :active="active('underline')" :aria-label="t('fields.richtext.underline')" @click="run(e => e.chain().focus().toggleUnderline().run())" />
|
|
115
|
+
<v-btn v-if="on('strike')" icon="mdi-format-strikethrough" size="small" variant="text" :active="active('strike')" :aria-label="t('fields.richtext.strike')" @click="run(e => e.chain().focus().toggleStrike().run())" />
|
|
116
|
+
<v-btn v-if="on('code')" icon="mdi-code-tags" size="small" variant="text" :active="active('code')" :aria-label="t('fields.richtext.code')" @click="run(e => e.chain().focus().toggleCode().run())" />
|
|
117
|
+
<template v-if="on('heading')">
|
|
118
|
+
<v-divider vertical class="mx-1" />
|
|
119
|
+
<v-btn v-for="level in ([1, 2, 3] as const)" :key="level" :icon="`mdi-format-header-${level}`" size="small" variant="text" :active="active('heading', { level })" :aria-label="t('fields.richtext.heading', { level })" @click="run(e => e.chain().focus().toggleHeading({ level }).run())" />
|
|
120
|
+
</template>
|
|
121
|
+
<template v-if="on('list')">
|
|
122
|
+
<v-divider vertical class="mx-1" />
|
|
123
|
+
<v-btn icon="mdi-format-list-bulleted" size="small" variant="text" :active="active('bulletList')" :aria-label="t('fields.richtext.bulletList')" @click="run(e => e.chain().focus().toggleBulletList().run())" />
|
|
124
|
+
<v-btn icon="mdi-format-list-numbered" size="small" variant="text" :active="active('orderedList')" :aria-label="t('fields.richtext.orderedList')" @click="run(e => e.chain().focus().toggleOrderedList().run())" />
|
|
125
|
+
</template>
|
|
126
|
+
<v-btn v-if="on('blockquote')" icon="mdi-format-quote-close" size="small" variant="text" :active="active('blockquote')" :aria-label="t('fields.richtext.blockquote')" @click="run(e => e.chain().focus().toggleBlockquote().run())" />
|
|
127
|
+
<v-btn v-if="on('codeBlock')" icon="mdi-code-braces" size="small" variant="text" :active="active('codeBlock')" :aria-label="t('fields.richtext.codeBlock')" @click="run(e => e.chain().focus().toggleCodeBlock().run())" />
|
|
128
|
+
<v-btn v-if="on('horizontalRule')" icon="mdi-minus" size="small" variant="text" :aria-label="t('fields.richtext.horizontalRule')" @click="run(e => e.chain().focus().setHorizontalRule().run())" />
|
|
129
|
+
<v-menu v-if="on('link')" v-model="linkMenu" :close-on-content-click="false" location="bottom">
|
|
130
|
+
<template #activator="{ props: activator }">
|
|
131
|
+
<v-btn v-bind="activator" icon="mdi-link-variant" size="small" variant="text" :active="active('link')" :aria-label="t('fields.richtext.link')" @click="openLink" />
|
|
132
|
+
</template>
|
|
133
|
+
<v-card min-width="320" class="pa-3">
|
|
134
|
+
<v-text-field v-model="linkHref" :label="t('fields.richtext.linkUrl')" :error-messages="linkError ? [linkError] : []" autofocus @keydown.enter.prevent="applyLink" />
|
|
135
|
+
<div class="d-flex justify-end ga-2">
|
|
136
|
+
<v-btn variant="text" @click="linkHref = ''; applyLink()">{{ t('common.remove') }}</v-btn>
|
|
137
|
+
<v-btn color="primary" :disabled="!!linkError" @click="applyLink">{{ t('common.apply') }}</v-btn>
|
|
138
|
+
</div>
|
|
139
|
+
</v-card>
|
|
140
|
+
</v-menu>
|
|
141
|
+
<template v-if="on('table')">
|
|
142
|
+
<v-divider vertical class="mx-1" />
|
|
143
|
+
<v-btn icon="mdi-table-plus" size="small" variant="text" :aria-label="t('fields.richtext.insertTable')" @click="run(e => e.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run())" />
|
|
144
|
+
<template v-if="inTable">
|
|
145
|
+
<v-btn icon="mdi-table-row-plus-after" size="small" variant="text" :aria-label="t('fields.richtext.addRow')" @click="run(e => e.chain().focus().addRowAfter().run())" />
|
|
146
|
+
<v-btn icon="mdi-table-column-plus-after" size="small" variant="text" :aria-label="t('fields.richtext.addColumn')" @click="run(e => e.chain().focus().addColumnAfter().run())" />
|
|
147
|
+
<v-btn icon="mdi-table-row-remove" size="small" variant="text" :aria-label="t('fields.richtext.deleteRow')" @click="run(e => e.chain().focus().deleteRow().run())" />
|
|
148
|
+
<v-btn icon="mdi-table-column-remove" size="small" variant="text" :aria-label="t('fields.richtext.deleteColumn')" @click="run(e => e.chain().focus().deleteColumn().run())" />
|
|
149
|
+
<v-btn icon="mdi-table-remove" size="small" variant="text" :aria-label="t('fields.richtext.deleteTable')" @click="run(e => e.chain().focus().deleteTable().run())" />
|
|
150
|
+
</template>
|
|
151
|
+
</template>
|
|
152
|
+
<template v-if="on('image') && rich.imageField">
|
|
153
|
+
<v-btn icon="mdi-image-plus" size="small" variant="text" :loading="uploading" :aria-label="t('fields.richtext.image')" @click="picker?.click()" />
|
|
154
|
+
<input ref="picker" type="file" accept="image/png,image/jpeg,image/webp,image/gif" class="d-none" @change="insertImage">
|
|
155
|
+
</template>
|
|
156
|
+
<v-spacer />
|
|
157
|
+
<v-btn icon="mdi-undo" size="small" variant="text" :aria-label="t('fields.richtext.undo')" @click="run(e => e.chain().focus().undo().run())" />
|
|
158
|
+
<v-btn icon="mdi-redo" size="small" variant="text" :aria-label="t('fields.richtext.redo')" @click="run(e => e.chain().focus().redo().run())" />
|
|
159
|
+
</div>
|
|
160
|
+
<v-divider />
|
|
161
|
+
<EditorContent :editor="editor" class="vx-richtext-editor" />
|
|
162
|
+
</div>
|
|
163
|
+
</v-input>
|
|
164
|
+
</template>
|
|
165
|
+
|
|
166
|
+
<style>
|
|
167
|
+
.vx-richtext .v-input__control { display: block; }
|
|
168
|
+
.vx-richtext-frame { border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity)); border-radius: 4px; width: 100%; }
|
|
169
|
+
.vx-richtext-frame--error { border-color: rgb(var(--v-theme-error)); }
|
|
170
|
+
.vx-richtext-editor .vx-richtext-content { min-height: 8rem; padding: 0.75rem 1rem; outline: none; }
|
|
171
|
+
.vx-richtext-content p.is-editor-empty:first-child::before { content: attr(data-placeholder); float: inline-start; height: 0; pointer-events: none; opacity: 0.5; }
|
|
172
|
+
.vx-richtext-content table, .vx-richtext-view table { border-collapse: collapse; margin: 0.5rem 0; }
|
|
173
|
+
.vx-richtext-content td, .vx-richtext-content th, .vx-richtext-view td, .vx-richtext-view th { border: 1px solid rgba(var(--v-border-color), 0.3); padding: 0.25rem 0.5rem; min-width: 4rem; vertical-align: top; }
|
|
174
|
+
.vx-richtext-content img, .vx-richtext-view img { max-width: 100%; height: auto; }
|
|
175
|
+
.vx-richtext-content blockquote, .vx-richtext-view blockquote { border-inline-start: 3px solid rgba(var(--v-border-color), 0.4); padding-inline-start: 0.75rem; margin: 0.5rem 0; }
|
|
176
|
+
.vx-richtext-content pre, .vx-richtext-view pre { background: rgba(var(--v-theme-on-surface), 0.06); padding: 0.5rem 0.75rem; border-radius: 4px; overflow-x: auto; }
|
|
177
|
+
.vx-richtext-content ul, .vx-richtext-content ol, .vx-richtext-view ul, .vx-richtext-view ol { padding-inline-start: 1.5rem; }
|
|
178
|
+
.vx-mention { color: rgb(var(--v-theme-primary)); font-weight: 500; }
|
|
179
|
+
.vx-mention-menu { position: absolute; z-index: 3000; background: rgb(var(--v-theme-surface)); border: 1px solid rgba(var(--v-border-color), 0.3); border-radius: 4px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); display: flex; flex-direction: column; min-width: 12rem; }
|
|
180
|
+
.vx-mention-choice { text-align: start; padding: 0.35rem 0.75rem; background: none; border: 0; cursor: pointer; }
|
|
181
|
+
.vx-mention-choice--active, .vx-mention-choice:hover { background: rgba(var(--v-theme-primary), 0.12); }
|
|
182
|
+
</style>
|